From c42d7ca0e131c47e05bb24b2de2c31f63e87d36a Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 23 Jul 2026 03:23:54 +0000 Subject: [PATCH] feat: implement user data schema and API endpoints - Add Alembic migration setup with async configuration - Create 16 user data models (users, decks, cards, replays, etc.) - Implement comprehensive API endpoints with JWT auth - Add replay, card collection, group, network, preferences, and activity log routers - Include API documentation and migration test plan - Update Dockerfile to run migrations on startup --- backend/API_DOCUMENTATION.md | 852 ++++++++ backend/Dockerfile | 9 +- backend/TEST_PLAN.md | 142 ++ backend/alembic.ini | 86 + backend/alembic/env.py | 86 + .../versions/001_initial_user_schema.py | 281 +++ backend/alembic/versions/__init__.py | 2 + backend/app/main.py | 3 +- backend/app/models/__init__.py | 21 + backend/app/models/user_data.py | 325 ++++ backend/app/routers/user_data.py | 1706 +++++++++++++++++ backend/app/schemas/user_data_schemas.py | 526 +++++ backend/scripts/run_migrations.sh | 32 + 13 files changed, 4068 insertions(+), 3 deletions(-) create mode 100644 backend/API_DOCUMENTATION.md create mode 100644 backend/TEST_PLAN.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/001_initial_user_schema.py create mode 100644 backend/alembic/versions/__init__.py create mode 100644 backend/app/models/user_data.py create mode 100644 backend/app/routers/user_data.py create mode 100644 backend/app/schemas/user_data_schemas.py create mode 100644 backend/scripts/run_migrations.sh diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md new file mode 100644 index 0000000..2154ca2 --- /dev/null +++ b/backend/API_DOCUMENTATION.md @@ -0,0 +1,852 @@ +# User Data API Endpoints - Complete Documentation + +## Overview + +The User Data API provides comprehensive CRUD operations for: +- **Session Management** - User authentication sessions +- **Deck Versions** - Deck version history and rollback +- **Game Replays** - Game recording and playback +- **Game Outcomes** - Win/loss tracking with ratings +- **User Statistics** - Denormalized stats (games, wins, streaks) +- **Card Collection** - User-owned cards with condition/language +- **Wishlist** - Cards users want to acquire +- **Groups** - User groups with roles and chat +- **Networks** - Extended social connections +- **Preferences** - User settings and preferences +- **Activity Log** - Audit trail with JSONB metadata + +## Base URL + +``` +/api/v1/user-data +``` + +## Authentication + +All endpoints require a valid JWT token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +--- + +## 1. Session Management + +### Get Active Sessions +```http +GET /sessions/me +``` + +**Response:** +```json +[ + { + "cleaned_count": 2, + "message": "Found 2 active sessions" + } +] +``` + +### Cleanup Expired Sessions +```http +DELETE /sessions/cleanup +``` + +**Response:** +```json +{ + "message": "Cleaned 5 expired sessions" +} +``` + +### Logout Current Session +```http +POST /sessions/logout +``` + +**Response:** +```json +{ + "message": "Logged out successfully" +} +``` + +--- + +## 2. Deck Versions + +### Create Deck Version +```http +POST /decks/{deck_id}/versions +Content-Type: application/json + +{ + "content": "4x Thoughtseize, 4x Lightning Bolt, ...", + "status": "DRAFT", + "comment": "Updated for meta change" +} +``` + +**Response:** +```json +{ + "id": 1, + "deck_id": 42, + "version_number": 3, + "content": "4x Thoughtseize, ...", + "status": "DRAFT", + "comment": "Updated for meta change", + "created_at": "2026-01-01T12:00:00Z" +} +``` + +### Get Deck Versions +```http +GET /decks/{deck_id}/versions?page=1&page_size=50 +``` + +**Response:** +```json +{ + "versions": [...], + "total": 10, + "page": 1, + "page_size": 50, + "total_pages": 1 +} +``` + +### Update Deck Version +```http +PATCH /decks/{deck_id}/versions/{version_id} +Content-Type: application/json + +{ + "status": "FINAL", + "comment": "Ready for tournament" +} +``` + +### Delete Deck Version +```http +DELETE /decks/{deck_id}/versions/{version_id} +``` + +--- + +## 3. Game Replays + +### Create Game Replay +```http +POST /replays +Content-Type: application/json + +{ + "game_uuid": "550e8400-e29b-41d4-a716-446655440000", + "room_id": 1, + "game_type": "Draft", + "format": "Standard", + "duration_seconds": 1800, + "start_time": "2026-01-01T12:00:00Z", + "end_time": "2026-01-01T12:30:00Z", + "status": "COMPLETED", + "replay_data": { + "turns": [...], + "deck": {...} + } +} +``` + +**Response:** +```json +{ + "id": 1, + "game_uuid": "550e8400-...", + "room_id": 1, + "game_type": "Draft", + "format": "Standard", + "duration_seconds": 1800, + "start_time": "2026-01-01T12:00:00Z", + "end_time": "2026-01-01T12:30:00Z", + "status": "COMPLETED", + "replay_data": {...}, + "created_at": "2026-01-01T12:30:00Z", + "updated_at": "2026-01-01T12:30:00Z", + "players": [...] +} +``` + +### Get Game Replays +```http +GET /replays?page=1&page_size=50&user_id=42&status_filter=COMPLETED +``` + +**Response:** +```json +{ + "replays": [...], + "total": 100, + "page": 1, + "page_size": 50, + "total_pages": 2 +} +``` + +### Get Replay Players +```http +GET /replays/{replay_id}/players +``` + +**Response:** +```json +[ + { + "id": 1, + "user_id": 42, + "deck_id": 10, + "position": 1, + "won": true, + "lost": false, + "concession": false, + "turn_one": false + } +] +``` + +--- + +## 4. Game Outcomes + +### Create Game Outcome +```http +POST /outcomes +Content-Type: application/json + +{ + "game_uuid": "550e8400-e29b-41d4-a716-446655440000", + "outcome": "WIN", + "opponent_id": 99, + "format": "Standard", + "rating_before": 1500, + "rating_after": 1525, + "rating_change": 25 +} +``` + +**Response:** +```json +{ + "id": 1, + "user_id": 42, + "game_uuid": "550e8400-...", + "outcome": "WIN", + "opponent_id": 99, + "format": "Standard", + "rating_before": 1500, + "rating_after": 1525, + "rating_change": 25, + "created_at": "2026-01-01T12:30:00Z" +} +``` + +### Get Game Outcomes +```http +GET /outcomes?page=1&page_size=50&user_id=42 +``` + +--- + +## 5. User Statistics + +### Get User Statistics +```http +GET /statistics/{user_id} +``` + +**Response:** +```json +{ + "user_id": 42, + "total_games": 150, + "total_wins": 90, + "total_losses": 55, + "total_concessions": 5, + "win_rate": 60.0, + "current_streak": 3, + "best_streak": 8, + "average_rating": 1450.5, + "last_game_date": "2026-01-01T12:00:00Z", + "updated_at": "2026-01-01T12:30:00Z" +} +``` + +### Update User Statistics +```http +POST /statistics/update +Content-Type: application/json + +{ + "user_id": 42, + "outcome": "WIN" +} +``` + +**Response:** +```json +{ + "user_id": 42, + "total_games": 151, + "total_wins": 91, + "total_losses": 55, + "win_rate": 60.26, + "current_streak": 4, + "updated_at": "2026-01-01T12:35:00Z" +} +``` + +--- + +## 6. Card Collection + +### Add Card to Collection +```http +POST /collection +Content-Type: application/json + +{ + "card_id": 12345, + "quantity": 4, + "condition": "NEAR_MINT", + "language": "EN", + "is_foil": true, + "is_alt_art": false, + "acquired_date": "2026-01-01T00:00:00Z", + "acquisition_method": "Bought", + "notes": "From card shop" +} +``` + +**Response:** +```json +{ + "id": 1, + "user_id": 42, + "card_id": 12345, + "quantity": 4, + "condition": "NEAR_MINT", + "language": "EN", + "is_foil": true, + "is_alt_art": false, + "acquired_date": "2026-01-01T00:00:00Z", + "acquisition_method": "Bought", + "notes": "From card shop", + "created_at": "2026-01-01T12:00:00Z", + "updated_at": "2026-01-01T12:00:00Z" +} +``` + +### Get Card Collection +```http +GET /collection?page=1&page_size=50&is_foil=true&is_alt_art=false +``` + +**Response:** +```json +{ + "cards": [...], + "total": 500, + "page": 1, + "page_size": 50, + "total_pages": 10 +} +``` + +### Update Card in Collection +```http +PATCH /collection/{card_id} +Content-Type: application/json + +{ + "quantity": 3, + "condition": "EX", + "notes": "Slightly worn" +} +``` + +### Remove Card from Collection +```http +DELETE /collection/{card_id} +``` + +--- + +## 7. Wishlist + +### Add to Wishlist +```http +POST /wishlist +Content-Type: application/json + +{ + "card_id": 12345, + "max_price": 50.00, + "notes": "Looking for foil version" +} +``` + +**Response:** +```json +{ + "id": 1, + "user_id": 42, + "card_id": 12345, + "max_price": 50.00, + "notes": "Looking for foil version", + "created_at": "2026-01-01T12:00:00Z" +} +``` + +### Get Wishlist +```http +GET /wishlist?page=1&page_size=50 +``` + +### Update Wishlist Item +```http +PATCH /wishlist/{item_id} +Content-Type: application/json + +{ + "max_price": 75.00, + "notes": "Willing to pay more" +} +``` + +### Remove from Wishlist +```http +DELETE /wishlist/{item_id} +``` + +--- + +## 8. Groups + +### Create Group +```http +POST /groups +Content-Type: application/json + +{ + "name": "Standard Players", + "description": "Casual Standard players", + "is_public": true, + "max_members": 50 +} +``` + +**Response:** +```json +{ + "id": 1, + "name": "Standard Players", + "description": "Casual Standard players", + "owner_id": 42, + "is_public": true, + "max_members": 50, + "created_at": "2026-01-01T12:00:00Z", + "updated_at": "2026-01-01T12:00:00Z", + "member_count": 1, + "is_member": true +} +``` + +### Get User Groups +```http +GET /groups?page=1&page_size=50&is_public=true +``` + +**Response:** +```json +{ + "groups": [...], + "total": 5, + "page": 1, + "page_size": 50, + "total_pages": 1 +} +``` + +### Update Group +```http +PATCH /groups/{group_id} +Content-Type: application/json + +{ + "description": "Updated description", + "max_members": 100 +} +``` + +### Delete Group +```http +DELETE /groups/{group_id} +``` + +--- + +## 9. Group Members + +### Add Group Member +```http +POST /groups/{group_id}/members +Content-Type: application/json + +{ + "user_id": 99, + "role": "MEMBER" +} +``` + +**Response:** +```json +{ + "message": "Member added", + "member_id": 5 +} +``` + +### Update Group Member Role +```http +PATCH /groups/{group_id}/members/{member_id} +Content-Type: application/json + +{ + "role": "ADMIN" +} +``` + +**Response:** +```json +{ + "message": "Member role updated" +} +``` + +### Remove Group Member +```http +DELETE /groups/{group_id}/members/{member_id} +``` + +--- + +## 10. Group Chat Messages + +### Send Group Message +```http +POST /groups/{group_id}/messages +Content-Type: application/json + +{ + "message": "Hey everyone! Ready for a game?" +} +``` + +**Response:** +```json +{ + "id": 1, + "group_id": 1, + "sender_id": 42, + "sender_username": "player42", + "message": "Hey everyone! Ready for a game?", + "created_at": "2026-01-01T12:00:00Z" +} +``` + +### Get Group Messages +```http +GET /groups/{group_id}/messages?page=1&page_size=50 +``` + +**Response:** +```json +{ + "messages": [...], + "total": 25, + "page": 1, + "page_size": 50, + "total_pages": 1 +} +``` + +--- + +## 11. Networks + +### Create Network +```http +POST /networks +Content-Type: application/json + +{ + "name": "MTG Enthusiasts", + "description": "Friends who play Magic", + "is_public": true +} +``` + +**Response:** +```json +{ + "id": 1, + "name": "MTG Enthusiasts", + "description": "Friends who play Magic", + "creator_id": 42, + "is_public": true, + "created_at": "2026-01-01T12:00:00Z", + "member_count": 1, + "is_member": true +} +``` + +### Get User Networks +```http +GET /networks?page=1&page_size=50 +``` + +### Update Network +```http +PATCH /networks/{network_id} +Content-Type: application/json + +{ + "description": "Updated network description" +} +``` + +### Delete Network +```http +DELETE /networks/{network_id} +``` + +--- + +## 12. Network Members + +### Add Network Member +```http +POST /networks/{network_id}/members +Content-Type: application/json + +{ + "user_id": 99, + "role": "MEMBER" +} +``` + +**Response:** +```json +{ + "message": "Member added", + "member_id": 3 +} +``` + +--- + +## 13. User Preferences + +### Get User Preferences +```http +GET /preferences +``` + +**Response:** +```json +{ + "user_id": 42, + "theme": "light", + "notifications_enabled": true, + "email_notifications": true, + "auto_save_decks": true, + "default_format": "standard", + "language": "EN", + "updated_at": "2026-01-01T12:00:00Z" +} +``` + +### Update User Preferences +```http +PATCH /preferences +Content-Type: application/json + +{ + "theme": "dark", + "notifications_enabled": false, + "default_format": "modern" +} +``` + +**Response:** +```json +{ + "user_id": 42, + "theme": "dark", + "notifications_enabled": false, + "email_notifications": true, + "auto_save_decks": true, + "default_format": "modern", + "language": "EN", + "updated_at": "2026-01-01T12:05:00Z" +} +``` + +--- + +## 14. Activity Log + +### Get Activity Log +```http +GET /activity?page=1&page_size=50&activity_type=LOGIN +``` + +**Response:** +```json +{ + "entries": [ + { + "id": 1, + "user_id": 42, + "activity_type": "LOGIN", + "activity_data": {"ip": "192.168.1.1"}, + "ip_address": "192.168.1.1", + "created_at": "2026-01-01T12:00:00Z" + } + ], + "total": 100, + "page": 1, + "page_size": 50, + "total_pages": 2 +} +``` + +--- + +## Error Responses + +All endpoints return consistent error responses: + +```json +{ + "detail": "Error message" +} +``` + +### Common Error Codes + +| Status Code | Description | +|------------|-------------| +| 400 | Bad Request - Invalid input | +| 401 | Unauthorized - Missing or invalid token | +| 403 | Forbidden - Insufficient permissions | +| 404 | Not Found - Resource doesn't exist | +| 409 | Conflict - Resource already exists | +| 500 | Internal Server Error | + +--- + +## Testing with cURL + +### Example: Create a Deck Version +```bash +curl -X POST "http://localhost:8000/api/v1/user-data/decks/42/versions" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "content": "4x Thoughtseize, 4x Lightning Bolt, ...", + "status": "DRAFT", + "comment": "Updated for meta" + }' +``` + +### Example: Get Card Collection +```bash +curl "http://localhost:8000/api/v1/user-data/collection?page=1&page_size=10" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +### Example: Add to Wishlist +```bash +curl -X POST "http://localhost:8000/api/v1/user-data/wishlist" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "card_id": 12345, + "max_price": 50.00, + "notes": "Looking for foil" + }' +``` + +--- + +## Available Endpoints Summary + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/sessions/me` | Get active sessions | +| DELETE | `/sessions/cleanup` | Cleanup expired sessions | +| POST | `/sessions/logout` | Logout current session | +| POST | `/decks/{id}/versions` | Create deck version | +| GET | `/decks/{id}/versions` | Get deck versions | +| PATCH | `/decks/{id}/versions/{vid}` | Update deck version | +| DELETE | `/decks/{id}/versions/{vid}` | Delete deck version | +| POST | `/replays` | Create game replay | +| GET | `/replays` | Get game replays | +| GET | `/replays/{id}` | Get specific replay | +| PATCH | `/replays/{id}` | Update replay | +| DELETE | `/replays/{id}` | Delete replay | +| POST | `/replays/{id}/players` | Add player to replay | +| GET | `/replays/{id}/players` | Get replay players | +| POST | `/outcomes` | Create game outcome | +| GET | `/outcomes` | Get game outcomes | +| GET | `/statistics/{id}` | Get user statistics | +| POST | `/statistics/update` | Update user statistics | +| POST | `/collection` | Add card to collection | +| GET | `/collection` | Get card collection | +| PATCH | `/collection/{id}` | Update card | +| DELETE | `/collection/{id}` | Remove card | +| POST | `/wishlist` | Add to wishlist | +| GET | `/wishlist` | Get wishlist | +| PATCH | `/wishlist/{id}` | Update wishlist item | +| DELETE | `/wishlist/{id}` | Remove from wishlist | +| POST | `/groups` | Create group | +| GET | `/groups` | Get user groups | +| GET | `/groups/{id}` | Get specific group | +| PATCH | `/groups/{id}` | Update group | +| DELETE | `/groups/{id}` | Delete group | +| POST | `/groups/{id}/members` | Add group member | +| PATCH | `/groups/{id}/members/{mid}` | Update member role | +| DELETE | `/groups/{id}/members/{mid}` | Remove member | +| POST | `/groups/{id}/messages` | Send group message | +| GET | `/groups/{id}/messages` | Get group messages | +| POST | `/networks` | Create network | +| GET | `/networks` | Get user networks | +| GET | `/networks/{id}` | Get specific network | +| PATCH | `/networks/{id}` | Update network | +| DELETE | `/networks/{id}` | Delete network | +| POST | `/networks/{id}/members` | Add network member | +| GET | `/preferences` | Get user preferences | +| PATCH | `/preferences` | Update preferences | +| GET | `/activity` | Get activity log | + +--- + +## Next Steps + +1. **Test endpoints** with curl or Postman +2. **Create integration tests** for each endpoint +3. **Add rate limiting** for production +4. **Implement pagination optimization** for large datasets +5. **Add search functionality** for cards and decks +6. **Create webhook endpoints** for real-time notifications diff --git a/backend/Dockerfile b/backend/Dockerfile index cd838d4..6efcf06 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -29,11 +29,15 @@ ENV PATH=/app/.local/bin:$PATH RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser -RUN mkdir -p /app/data /app/uploads /app/logs /app/scripts && chown -R appuser:appuser /app +RUN mkdir -p /app/data /app/uploads /app/logs /app/scripts /app/alembic/versions && chown -R appuser:appuser /app # Copy application code COPY --chown=appuser:appuser app/ ./app/ +# Copy Alembic configuration +COPY --chown=appuser:appuser alembic.ini ./ +COPY --chown=appuser:appuser alembic/ ./alembic/ + # Copy interaction pipeline scripts COPY --chown=appuser:appuser scripts/ ./scripts/ RUN chmod +x /app/scripts/*.py @@ -52,4 +56,5 @@ USER appuser EXPOSE 8000 -CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run migrations before starting the app +CMD ["sh", "-c", "python -m alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/TEST_PLAN.md b/backend/TEST_PLAN.md new file mode 100644 index 0000000..b9ef871 --- /dev/null +++ b/backend/TEST_PLAN.md @@ -0,0 +1,142 @@ +# Alembic Migration Test Plan + +## Overview +Test the Alembic migration setup to verify all user data tables are created correctly in PostgreSQL. + +## Test Steps + +### 1. Verify File Structure +- [x] Create `alembic.ini` with database URL configuration +- [x] Create `alembic/env.py` with async Alembic environment +- [x] Create `alembic/versions/001_initial_user_schema.py` with migration script +- [x] Create `alembic/versions/__init__.py` +- [x] Create `app/models/user_data.py` with all new models +- [x] Update `app/models/__init__.py` to import new models +- [x] Update `Dockerfile` to run migrations on container startup +- [x] Create `scripts/run_migrations.sh` for migration execution + +### 2. Test Migration Execution +- [ ] Verify Alembic configuration is correct +- [ ] Test migration in offline mode +- [ ] Test migration in online mode (if database is available) +- [ ] Verify all tables are created with correct schema + +### 3. Verify Schema Structure +- [ ] Check all 16 tables are created +- [ ] Verify foreign key relationships +- [ ] Verify indexes are created +- [ ] Verify constraints (UNIQUE, CHECK) + +### 4. Test Data Operations +- [ ] Insert test data into each table +- [ ] Verify CASCADE deletes work correctly +- [ ] Verify UNIQUE constraints prevent duplicates +- [ ] Verify JSONB columns store data correctly + +### 5. Test Rollback +- [ ] Execute downgrade migration +- [ ] Verify all tables are dropped +- [ ] Verify columns are removed from existing tables + +## Files Created + +### Core Alembic Files +1. **alembic.ini** - Alembic configuration with database URL +2. **alembic/env.py** - Async Alembic environment for PostgreSQL +3. **alembic/versions/001_initial_user_schema.py** - Initial migration script + +### New Models +4. **app/models/user_data.py** - All user data models (16 models) + - UserSession, DeckVersion, GameReplay, ReplayPlayer + - GameOutcome, UserStatistics, UserCardCollection, CardWishlist + - UserGroup, GroupMember, GroupChatMessage + - UserNetwork, NetworkMember, UserPreference, UserActivityLog + +### Updated Files +5. **app/models/__init__.py** - Added imports for new models +6. **Dockerfile** - Added migration step to container startup +7. **scripts/run_migrations.sh** - Migration execution script + +## Expected Tables + +### User Authentication +1. `user_sessions` - Session management with token hashing + +### Deck Management +2. `mtgonline_decklist_files` - Enhanced with description, format, etc. +3. `deck_versions` - Deck version history + +### Game Tracking +4. `game_replays` - Game replay recordings +5. `replay_players` - Players in game replays +6. `game_outcomes` - Game win/loss records +7. `user_statistics` - User game statistics summary + +### Card Collection +8. `user_card_collection` - User-owned cards +9. `card_wishlist` - Cards users want + +### Social Features +10. `user_groups` - User groups +11. `group_members` - Group membership +12. `group_chat_messages` - Group chat +13. `user_networks` - Extended social connections +14. `network_members` - Network membership + +### User Settings +15. `user_preferences` - User preferences and settings +16. `user_activity_log` - User activity tracking + +## Migration Commands + +### Run Migrations +```bash +# Online mode (requires database connection) +alembic upgrade head + +# Offline mode (for testing schema generation) +alembic upgrade head --sql + +# Check migration status +alembic current +alembic history + +# Generate new migration (after model changes) +alembic revision --autogenerate -m "Description" +``` + +### Test Commands +```bash +# Test alembic configuration +alembic --config alembic.ini current + +# Test migration generation +alembic --config alembic.ini upgrade head --sql + +# Run migration +alembic --config alembic.ini upgrade head +``` + +## Success Criteria + +- [ ] All 16 tables created successfully +- [ ] All foreign keys established correctly +- [ ] All indexes created for performance +- [ ] All constraints enforced properly +- [ ] Migration can be rolled back successfully +- [ ] Container starts with migrations applied + +## Potential Issues + +1. **Database connection** - Ensure PostgreSQL is accessible at `postgres:5432` +2. **Model imports** - Verify all models are imported in env.py +3. **Column conflicts** - Check for existing columns in mtgonline_decklist_files +4. **Index naming** - Ensure index names don't conflict with existing indexes + +## Next Steps + +1. Run the container and verify migrations execute +2. Test data insertion and retrieval +3. Verify CASCADE deletes work correctly +4. Test downgrade migration +5. Create API endpoints for new features diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..2942fcb --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,86 @@ +# 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>=3.9 or python-dateutil library. +# https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-the-_new_tzinfo_techique_to_run_in_a_specific_timezone +# 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 .py source will be used as the source for the executed +# .py source files. +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --start-version. +# version_path_separator = os + +# output encoding. If set to utf-8, it will encode all output for utf-8 encoding. +# If set to utf-8-sig, the BOM will be written to the output. +# This is useful for files that will be opened in Windows editors. +output_encoding = utf-8 + +sqlalchemy.url = postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See https://alembic.sqlalchemy.org/en/latest/hooks.html +# for hooks documentation. + +# Logging configuration +[loggers] +keys = root, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..9546f8e --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,86 @@ +""" +Alembic environment configuration for async SQLAlchemy. + +Supports async database operations for migrations. +""" +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Import all models so Alembic can detect changes +from app.core.database import Base +from app.models import ( + User, DecklistFile, DecklistFolder, Room, RoomGameType, + Ban, GameLog, AuditLog, MtgCardMirror, DeckCardLink +) +# Import new models +from app.models.user_data import ( + UserSession, DeckVersion, GameReplay, ReplayPlayer, + GameOutcome, UserStatistics, UserCardCollection, CardWishlist, + UserGroup, GroupMember, GroupChatMessage, UserNetwork, + NetworkMember, UserPreference, UserActivityLog +) + +# this is the Alembic Config object +config = context.config + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine instance though. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Run migrations with proper async context.""" + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async engine.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + options={"pool_pre_ping": True}, + class_=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/versions/001_initial_user_schema.py b/backend/alembic/versions/001_initial_user_schema.py new file mode 100644 index 0000000..4056d54 --- /dev/null +++ b/backend/alembic/versions/001_initial_user_schema.py @@ -0,0 +1,281 @@ +"""initial user schema + +Revision ID: 001 +Revises: +Create Date: 2026-01-01 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '001' +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: + """Create all user data tables.""" + + # 1. User Sessions Table + op.create_table( + 'user_sessions', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False), + sa.Column('session_token_hash', sa.String(255), unique=True, nullable=False), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('is_active', sa.Boolean(), default=True), + ) + op.create_index('idx_sessions_user', 'user_sessions', ['user_id']) + op.create_index('idx_sessions_token', 'user_sessions', ['session_token_hash']) + op.create_index('idx_sessions_expires', 'user_sessions', ['expires_at']) + + # 2. Enhanced Decklist File columns + op.add_column('mtgonline_decklist_files', sa.Column('description', sa.Text(), nullable=True)) + op.add_column('mtgonline_decklist_files', sa.Column('format', sa.String(50), server_default='standard')) + op.add_column('mtgonline_decklist_files', sa.Column('is_favorite', sa.Boolean(), default=False)) + op.add_column('mtgonline_decklist_files', sa.Column('import_source', sa.String(50), nullable=True)) + op.add_column('mtgonline_decklist_files', sa.Column('import_confidence', sa.Float(), nullable=True)) + op.add_column('mtgonline_decklist_files', sa.Column('last_played', sa.DateTime(), nullable=True)) + + # 3. Deck Versions Table + op.create_table( + 'deck_versions', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('deck_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_files.id', ondelete='CASCADE'), nullable=False), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('status', sa.String(20), server_default='DRAFT'), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_deck_versions_deck', 'deck_versions', ['deck_id']) + + # 4. Game Replays Table + op.create_table( + 'game_replays', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('game_uuid', sa.String(36), unique=True, nullable=False), + sa.Column('room_id', sa.Integer(), sa.ForeignKey('mtgonline_rooms.id'), nullable=True), + sa.Column('game_type', sa.String(50), nullable=True), + sa.Column('format', sa.String(50), nullable=True), + sa.Column('duration_seconds', sa.Integer(), nullable=True), + sa.Column('start_time', sa.DateTime(), nullable=False), + sa.Column('end_time', sa.DateTime(), nullable=True), + sa.Column('status', sa.String(20), server_default='IN_PROGRESS'), + sa.Column('replay_data', sa.JSON(), 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_replays_room', 'game_replays', ['room_id']) + op.create_index('idx_replays_start', 'game_replays', ['start_time']) + op.create_index('idx_replays_status', 'game_replays', ['status']) + + # 5. Replay Players Table + op.create_table( + 'replay_players', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('replay_id', sa.BigInteger(), sa.ForeignKey('game_replays.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('position', sa.Integer(), nullable=True), + sa.Column('deck_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_files.id'), nullable=True), + sa.Column('won', sa.Boolean(), nullable=True), + sa.Column('lost', sa.Boolean(), nullable=True), + sa.Column('concession', sa.Boolean(), default=False), + sa.Column('turn_one', sa.Boolean(), default=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_replay_players_replay', 'replay_players', ['replay_id']) + op.create_index('idx_replay_players_user', 'replay_players', ['user_id']) + + # 6. Game Outcomes Table + op.create_table( + 'game_outcomes', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('game_uuid', sa.String(36), sa.ForeignKey('game_replays.game_uuid'), nullable=False), + sa.Column('outcome', sa.String(20), nullable=False), + sa.Column('opponent_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=True), + sa.Column('format', sa.String(50), nullable=True), + sa.Column('rating_before', sa.Integer(), nullable=True), + sa.Column('rating_after', sa.Integer(), nullable=True), + sa.Column('rating_change', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_outcomes_user', 'game_outcomes', ['user_id']) + op.create_index('idx_outcomes_game', 'game_outcomes', ['game_uuid']) + op.create_index('idx_outcomes_outcome', 'game_outcomes', ['outcome']) + + # 7. User Statistics Table + op.create_table( + 'user_statistics', + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), primary_key=True), + sa.Column('total_games', sa.Integer(), default=0), + sa.Column('total_wins', sa.Integer(), default=0), + sa.Column('total_losses', sa.Integer(), default=0), + sa.Column('total_concessions', sa.Integer(), default=0), + sa.Column('win_rate', sa.Float(), default=0.0), + sa.Column('current_streak', sa.Integer(), default=0), + sa.Column('best_streak', sa.Integer(), default=0), + sa.Column('average_rating', sa.Float(), default=0.0), + sa.Column('last_game_date', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), + ) + + # 8. User Card Collection Table + op.create_table( + 'user_card_collection', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False), + sa.Column('card_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), default=1), + sa.Column('condition', sa.String(20), server_default='NEAR_MINT'), + sa.Column('language', sa.String(5), server_default='EN'), + sa.Column('is_foil', sa.Boolean(), default=False), + sa.Column('is_alt_art', sa.Boolean(), default=False), + sa.Column('acquired_date', sa.DateTime(), server_default=sa.func.now()), + sa.Column('acquisition_method', sa.String(50), nullable=True), + sa.Column('notes', sa.Text(), 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_collection_user', 'user_card_collection', ['user_id']) + op.create_index('idx_collection_card', 'user_card_collection', ['card_id']) + op.create_unique_constraint('uq_collection_unique', 'user_card_collection', ['user_id', 'card_id', 'is_foil', 'is_alt_art']) + + # 9. Card Wishlist Table + op.create_table( + 'card_wishlist', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False), + sa.Column('card_id', sa.Integer(), nullable=False), + sa.Column('max_price', sa.Float(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_unique_constraint('uq_wishlist_user_card', 'card_wishlist', ['user_id', 'card_id']) + + # 10. User Groups Table + op.create_table( + 'user_groups', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('owner_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('is_public', sa.Boolean(), default=True), + sa.Column('max_members', sa.Integer(), default=50), + 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_groups_owner', 'user_groups', ['owner_id']) + + # 11. Group Members Table + op.create_table( + 'group_members', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('group_id', sa.BigInteger(), sa.ForeignKey('user_groups.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('role', sa.String(20), server_default='MEMBER'), + sa.Column('joined_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_members_group', 'group_members', ['group_id']) + op.create_index('idx_members_user', 'group_members', ['user_id']) + op.create_unique_constraint('uq_group_member', 'group_members', ['group_id', 'user_id']) + + # 12. Group Chat Messages Table + op.create_table( + 'group_chat_messages', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('group_id', sa.BigInteger(), sa.ForeignKey('user_groups.id', ondelete='CASCADE'), nullable=False), + sa.Column('sender_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_group_messages_group', 'group_chat_messages', ['group_id']) + op.create_index('idx_group_messages_sender', 'group_chat_messages', ['sender_id']) + op.create_index('idx_group_messages_created', 'group_chat_messages', ['created_at']) + + # 13. User Networks Table + op.create_table( + 'user_networks', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('creator_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('is_public', sa.Boolean(), default=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + + # 14. Network Members Table + op.create_table( + 'network_members', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('network_id', sa.BigInteger(), sa.ForeignKey('user_networks.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('role', sa.String(20), server_default='MEMBER'), + sa.Column('joined_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_network_members_network', 'network_members', ['network_id']) + op.create_index('idx_network_members_user', 'network_members', ['user_id']) + op.create_unique_constraint('uq_network_member', 'network_members', ['network_id', 'user_id']) + + # 15. User Preferences Table + op.create_table( + 'user_preferences', + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), primary_key=True), + sa.Column('theme', sa.String(20), server_default='light'), + sa.Column('notifications_enabled', sa.Boolean(), default=True), + sa.Column('email_notifications', sa.Boolean(), default=True), + sa.Column('auto_save_decks', sa.Boolean(), default=True), + sa.Column('default_format', sa.String(50), server_default='standard'), + sa.Column('language', sa.String(5), server_default='EN'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), + ) + + # 16. User Activity Log Table + op.create_table( + 'user_activity_log', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('activity_type', sa.String(50), nullable=False), + sa.Column('activity_data', sa.JSON(), nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_activity_user', 'user_activity_log', ['user_id']) + op.create_index('idx_activity_type', 'user_activity_log', ['activity_type']) + op.create_index('idx_activity_created', 'user_activity_log', ['created_at']) + + +def downgrade() -> None: + """Drop all user data tables.""" + op.drop_table('user_activity_log') + op.drop_table('user_preferences') + op.drop_table('network_members') + op.drop_table('user_networks') + op.drop_table('group_chat_messages') + op.drop_table('group_members') + op.drop_table('user_groups') + op.drop_table('card_wishlist') + op.drop_table('user_card_collection') + op.drop_table('user_statistics') + op.drop_table('game_outcomes') + op.drop_table('replay_players') + op.drop_table('game_replays') + op.drop_table('deck_versions') + op.drop_table('user_sessions') + + # Drop added columns from existing table + op.drop_column('mtgonline_decklist_files', 'last_played') + op.drop_column('mtgonline_decklist_files', 'import_confidence') + op.drop_column('mtgonline_decklist_files', 'import_source') + op.drop_column('mtgonline_decklist_files', 'is_favorite') + op.drop_column('mtgonline_decklist_files', 'format') + op.drop_column('mtgonline_decklist_files', 'description') diff --git a/backend/alembic/versions/__init__.py b/backend/alembic/versions/__init__.py new file mode 100644 index 0000000..0bc192e --- /dev/null +++ b/backend/alembic/versions/__init__.py @@ -0,0 +1,2 @@ +# Alembic migration scripts +# These files are generated by Alembic and should not be edited diff --git a/backend/app/main.py b/backend/app/main.py index c76c7cf..60e0272 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.settings import get_settings from app.core.database import engine, mtg_engine, async_session, mtg_async_session -from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh +from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh, user_data from app.services.mtgjson_manager import MTGJSONManager @@ -140,6 +140,7 @@ app.include_router(admin.router, prefix="/admin", tags=["Admin"]) app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"]) app.include_router(interactions.router, tags=["Card Interactions"]) app.include_router(refresh.router) +app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"]) @app.get("/health", tags=["Health"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8132120..c39ee25 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,12 @@ from app.models.models import User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog from app.models.mtg_models import MtgSet, MtgCard from app.models.mirror_models import MtgCardMirror, DeckCardLink +from app.models.user_data import ( + UserSession, DeckVersion, GameReplay, ReplayPlayer, + GameOutcome, UserStatistics, UserCardCollection, CardWishlist, + UserGroup, GroupMember, GroupChatMessage, UserNetwork, + NetworkMember, UserPreference, UserActivityLog +) __all__ = [ "User", @@ -16,4 +22,19 @@ __all__ = [ "MtgCard", "MtgCardMirror", "DeckCardLink", + "UserSession", + "DeckVersion", + "GameReplay", + "ReplayPlayer", + "GameOutcome", + "UserStatistics", + "UserCardCollection", + "CardWishlist", + "UserGroup", + "GroupMember", + "GroupChatMessage", + "UserNetwork", + "NetworkMember", + "UserPreference", + "UserActivityLog", ] diff --git a/backend/app/models/user_data.py b/backend/app/models/user_data.py new file mode 100644 index 0000000..eac78dd --- /dev/null +++ b/backend/app/models/user_data.py @@ -0,0 +1,325 @@ +""" +SQLAlchemy ORM models for user data features. + +Includes sessions, decks, replays, cards, groups, and networks. +""" +from sqlalchemy import ( + Column, Integer, String, BigInteger, Boolean, DateTime, Text, + ForeignKey, Index, UniqueConstraint, Float, JSON +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + + +class UserSession(Base): + """User authentication session.""" + __tablename__ = "user_sessions" + + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True) + session_token_hash = Column(String(255), unique=True, nullable=False, index=True) + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + expires_at = Column(DateTime, nullable=False) + is_active = Column(Boolean, default=True) + + user = relationship("User", backref="sessions") + + def __repr__(self) -> str: + return f"" + + +class DeckVersion(Base): + """Deck version history.""" + __tablename__ = "deck_versions" + + id = Column(BigInteger, primary_key=True) + deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"), nullable=False, index=True) + version_number = Column(Integer, nullable=False) + content = Column(Text, nullable=False) + status = Column(String(20), server_default="DRAFT") + comment = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + deck = relationship("DecklistFile", backref="versions") + + def __repr__(self) -> str: + return f"" + + +class GameReplay(Base): + """Game replay recording.""" + __tablename__ = "game_replays" + + id = Column(BigInteger, primary_key=True) + game_uuid = Column(String(36), unique=True, nullable=False) + room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=True, index=True) + game_type = Column(String(50), nullable=True) + format = Column(String(50), nullable=True) + duration_seconds = Column(Integer, nullable=True) + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=True) + status = Column(String(20), server_default="IN_PROGRESS") + replay_data = Column(JSON, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + players = relationship("ReplayPlayer", back_populates="replay", cascade="all, delete-orphan") + outcomes = relationship("GameOutcome", back_populates="replay", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + +class ReplayPlayer(Base): + """Player in a game replay.""" + __tablename__ = "replay_players" + + id = Column(BigInteger, primary_key=True) + replay_id = Column(BigInteger, ForeignKey("game_replays.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True) + position = Column(Integer, nullable=True) + deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id"), nullable=True) + won = Column(Boolean, nullable=True) + lost = Column(Boolean, nullable=True) + concession = Column(Boolean, default=False) + turn_one = Column(Boolean, default=False) + created_at = Column(DateTime, server_default=func.now()) + + replay = relationship("GameReplay", back_populates="players") + user = relationship("User") + deck = relationship("DecklistFile") + + def __repr__(self) -> str: + return f"" + + +class GameOutcome(Base): + """Game outcome record.""" + __tablename__ = "game_outcomes" + + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True) + game_uuid = Column(String(36), ForeignKey("game_replays.game_uuid"), nullable=False, index=True) + outcome = Column(String(20), nullable=False, index=True) + opponent_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True) + format = Column(String(50), nullable=True) + rating_before = Column(Integer, nullable=True) + rating_after = Column(Integer, nullable=True) + rating_change = Column(Integer, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + replay = relationship("GameReplay", back_populates="outcomes") + user = relationship("User", foreign_keys=[user_id]) + opponent = relationship("User", foreign_keys=[opponent_id]) + + def __repr__(self) -> str: + return f"" + + +class UserStatistics(Base): + """User game statistics summary.""" + __tablename__ = "user_statistics" + + user_id = Column(Integer, ForeignKey("mtgonline_users.id"), primary_key=True) + total_games = Column(Integer, default=0) + total_wins = Column(Integer, default=0) + total_losses = Column(Integer, default=0) + total_concessions = Column(Integer, default=0) + win_rate = Column(Float, default=0.0) + current_streak = Column(Integer, default=0) + best_streak = Column(Integer, default=0) + average_rating = Column(Float, default=0.0) + last_game_date = Column(DateTime, nullable=True) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + user = relationship("User") + + def __repr__(self) -> str: + return f"" + + +class UserCardCollection(Base): + """User card collection.""" + __tablename__ = "user_card_collection" + + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True) + card_id = Column(Integer, nullable=False, index=True) + quantity = Column(Integer, default=1) + condition = Column(String(20), server_default="NEAR_MINT") + language = Column(String(5), server_default="EN") + is_foil = Column(Boolean, default=False) + is_alt_art = Column(Boolean, default=False) + acquired_date = Column(DateTime, server_default=func.now()) + acquisition_method = Column(String(50), nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint('user_id', 'card_id', 'is_foil', 'is_alt_art', name='uq_collection_unique'), + Index('idx_collection_user_card', 'user_id', 'card_id'), + ) + + user = relationship("User", backref="card_collection") + + def __repr__(self) -> str: + return f"" + + +class CardWishlist(Base): + """User card wishlist.""" + __tablename__ = "card_wishlist" + + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False) + card_id = Column(Integer, nullable=False) + max_price = Column(Float, nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + __table_args__ = ( + UniqueConstraint('user_id', 'card_id', name='uq_wishlist_user_card'), + ) + + user = relationship("User", backref="wishlist") + + def __repr__(self) -> str: + return f"" + + +class UserGroup(Base): + """User group.""" + __tablename__ = "user_groups" + + id = Column(BigInteger, primary_key=True) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True) + is_public = Column(Boolean, default=True) + max_members = Column(Integer, default=50) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + owner = relationship("User", foreign_keys=[owner_id]) + members = relationship("GroupMember", back_populates="group", cascade="all, delete-orphan") + messages = relationship("GroupChatMessage", back_populates="group", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + +class GroupMember(Base): + """Group member.""" + __tablename__ = "group_members" + + id = Column(BigInteger, primary_key=True) + group_id = Column(BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True) + role = Column(String(20), server_default="MEMBER") + joined_at = Column(DateTime, server_default=func.now()) + + group = relationship("UserGroup", back_populates="members") + user = relationship("User") + + __table_args__ = ( + UniqueConstraint('group_id', 'user_id', name='uq_group_member'), + ) + + def __repr__(self) -> str: + return f"" + + +class GroupChatMessage(Base): + """Group chat message.""" + __tablename__ = "group_chat_messages" + + id = Column(BigInteger, primary_key=True) + group_id = Column(BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"), nullable=False, index=True) + sender_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True) + message = Column(Text, nullable=False) + created_at = Column(DateTime, server_default=func.now(), index=True) + + group = relationship("UserGroup", back_populates="messages") + sender = relationship("User", foreign_keys=[sender_id]) + + def __repr__(self) -> str: + return f"" + + +class UserNetwork(Base): + """User network (extended social connection).""" + __tablename__ = "user_networks" + + id = Column(BigInteger, primary_key=True) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + creator_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False) + is_public = Column(Boolean, default=True) + created_at = Column(DateTime, server_default=func.now()) + + creator = relationship("User", foreign_keys=[creator_id]) + members = relationship("NetworkMember", back_populates="network", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + +class NetworkMember(Base): + """Network member.""" + __tablename__ = "network_members" + + id = Column(BigInteger, primary_key=True) + network_id = Column(BigInteger, ForeignKey("user_networks.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True) + role = Column(String(20), server_default="MEMBER") + joined_at = Column(DateTime, server_default=func.now()) + + network = relationship("UserNetwork", back_populates="members") + user = relationship("User") + + __table_args__ = ( + UniqueConstraint('network_id', 'user_id', name='uq_network_member'), + ) + + def __repr__(self) -> str: + return f"" + + +class UserPreference(Base): + """User preferences and settings.""" + __tablename__ = "user_preferences" + + user_id = Column(Integer, ForeignKey("mtgonline_users.id"), primary_key=True) + theme = Column(String(20), server_default="light") + notifications_enabled = Column(Boolean, default=True) + email_notifications = Column(Boolean, default=True) + auto_save_decks = Column(Boolean, default=True) + default_format = Column(String(50), server_default="standard") + language = Column(String(5), server_default="EN") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + user = relationship("User") + + def __repr__(self) -> str: + return f"" + + +class UserActivityLog(Base): + """User activity log.""" + __tablename__ = "user_activity_log" + + id = Column(BigInteger, primary_key=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True) + activity_type = Column(String(50), nullable=False, index=True) + activity_data = Column(JSON, nullable=True) + ip_address = Column(String(45), nullable=True) + created_at = Column(DateTime, server_default=func.now(), index=True) + + user = relationship("User", backref="activity_logs") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/routers/user_data.py b/backend/app/routers/user_data.py new file mode 100644 index 0000000..034e583 --- /dev/null +++ b/backend/app/routers/user_data.py @@ -0,0 +1,1706 @@ +""" +User data API endpoints router. + +Provides CRUD operations for user sessions, decks, replays, +card collection, groups, networks, preferences, and activity logs. +""" +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update, delete +from typing import Optional, List +from datetime import datetime, timezone + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.models import User +from app.models.user_data import ( + UserSession, DeckVersion, GameReplay, ReplayPlayer, + GameOutcome, UserStatistics, UserCardCollection, CardWishlist, + UserGroup, GroupMember, GroupChatMessage, UserNetwork, + NetworkMember, UserPreference, UserActivityLog +) +from app.schemas.user_data_schemas import ( + SessionCleanupResponse, + DeckVersionCreate, DeckVersionUpdate, DeckVersionResponse, DeckVersionListResponse, + GameReplayCreate, GameReplayUpdate, GameReplayResponse, GameReplayListResponse, + GameOutcomeCreate, GameOutcomeResponse, GameOutcomeListResponse, + UserStatisticsResponse, StatisticsUpdateResponse, + CardCollectionCreate, CardCollectionUpdate, CardCollectionResponse, CardCollectionListResponse, + WishlistCreate, WishlistUpdate, WishlistResponse, WishlistListResponse, + GroupCreate, GroupUpdate, GroupResponse, GroupListResponse, + GroupMemberCreate, GroupMemberUpdate, GroupMemberRemove, + GroupChatMessageCreate, GroupChatMessageResponse, GroupChatMessageListResponse, + NetworkCreate, NetworkUpdate, NetworkResponse, NetworkListResponse, + NetworkMemberCreate, + UserPreferenceUpdate, UserPreferenceResponse, + ActivityLogEntry, ActivityLogListResponse, + MessageResponse, CountResponse, ErrorDetail +) + +router = APIRouter() + + +# ===== Session Management ===== + +@router.get("/sessions/me", response_model=List[SessionCleanupResponse]) +async def get_current_sessions( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get active sessions for current user.""" + user_id = int(current_user["user_id"]) + + stmt = ( + select(UserSession) + .where( + UserSession.user_id == user_id, + UserSession.is_active == True, + UserSession.expires_at > datetime.now(timezone.utc) + ) + ) + result = await db.execute(stmt) + sessions = result.scalars().all() + + return [ + SessionCleanupResponse( + cleaned_count=len(sessions), + message=f"Found {len(sessions)} active sessions" + ) + ] + + +@router.delete("/sessions/cleanup", response_model=MessageResponse) +async def cleanup_expired_sessions( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Cleanup expired sessions for current user.""" + user_id = int(current_user["user_id"]) + + now = datetime.now(timezone.utc) + + stmt = ( + update(UserSession) + .where( + UserSession.user_id == user_id, + UserSession.is_active == True, + UserSession.expires_at < now + ) + .values(is_active=False) + ) + await db.execute(stmt) + await db.flush() + + # Count cleaned sessions + count_stmt = select(func.count()).select_from(UserSession).where( + UserSession.user_id == user_id, + UserSession.is_active == False, + UserSession.expires_at < now + ) + count_result = await db.execute(count_stmt) + cleaned_count = count_result.scalar() + + return MessageResponse(message=f"Cleaned {cleaned_count} expired sessions") + + +@router.post("/sessions/logout", response_model=MessageResponse) +async def logout_current_session( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Logout current user session.""" + user_id = int(current_user["user_id"]) + + stmt = ( + update(UserSession) + .where( + UserSession.user_id == user_id, + UserSession.is_active == True + ) + .values(is_active=False) + ) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Logged out successfully") + + +# ===== Deck Versions ===== + +@router.post("/decks/{deck_id}/versions", response_model=DeckVersionResponse, status_code=status.HTTP_201_CREATED) +async def create_deck_version( + deck_id: int, + request: DeckVersionCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new version of a deck.""" + user_id = int(current_user["user_id"]) + + # Verify user owns the deck + deck_check = select(User).where(User.id == user_id) + deck_result = await db.execute(deck_check) + user = deck_result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + # Get current version number + version_stmt = ( + select(func.max(DeckVersion.version_number)) + .where(DeckVersion.deck_id == deck_id) + ) + version_result = await db.execute(version_stmt) + max_version = version_result.scalar() or 0 + + new_version = DeckVersion( + deck_id=deck_id, + version_number=max_version + 1, + content=request.content, + status=request.status.value, + comment=request.comment, + created_at=datetime.now(timezone.utc) + ) + + db.add(new_version) + await db.flush() + + return DeckVersionResponse.model_validate(new_version) + + +@router.get("/decks/{deck_id}/versions", response_model=DeckVersionListResponse) +async def get_deck_versions( + deck_id: int, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get all versions of a deck.""" + offset = (page - 1) * page_size + + count_stmt = select(func.count()).select_from(DeckVersion).where(DeckVersion.deck_id == deck_id) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + stmt = ( + select(DeckVersion) + .where(DeckVersion.deck_id == deck_id) + .order_by(DeckVersion.version_number.desc()) + .offset(offset) + .limit(page_size) + ) + result = await db.execute(stmt) + versions = result.scalars().all() + + return DeckVersionListResponse( + versions=[DeckVersionResponse.model_validate(v) for v in versions], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +@router.patch("/decks/{deck_id}/versions/{version_id}", response_model=DeckVersionResponse) +async def update_deck_version( + deck_id: int, + version_id: int, + request: DeckVersionUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a deck version.""" + stmt = select(DeckVersion).where(DeckVersion.id == version_id) + result = await db.execute(stmt) + version = result.scalar_one_or_none() + + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") + + update_data = request.model_dump(exclude_unset=True) + if "status" in update_data: + update_data["status"] = update_data["status"].value + + stmt = ( + update(DeckVersion) + .where(DeckVersion.id == version_id) + .values(**update_data) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(DeckVersion).where(DeckVersion.id == version_id) + result = await db.execute(stmt) + updated_version = result.scalar_one_or_none() + + return DeckVersionResponse.model_validate(updated_version) + + +@router.delete("/decks/{deck_id}/versions/{version_id}", response_model=MessageResponse) +async def delete_deck_version( + deck_id: int, + version_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a deck version.""" + stmt = select(DeckVersion).where(DeckVersion.id == version_id) + result = await db.execute(stmt) + version = result.scalar_one_or_none() + + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found") + + stmt = delete(DeckVersion).where(DeckVersion.id == version_id) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Version deleted successfully") + + +# ===== Game Replays ===== + +@router.post("/replays", response_model=GameReplayResponse, status_code=status.HTTP_201_CREATED) +async def create_game_replay( + request: GameReplayCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new game replay.""" + # Check if replay already exists + stmt = select(GameReplay).where(GameReplay.game_uuid == request.game_uuid) + result = await db.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Replay already exists") + + replay = GameReplay( + game_uuid=request.game_uuid, + room_id=request.room_id, + game_type=request.game_type, + format=request.format, + duration_seconds=request.duration_seconds, + start_time=request.start_time, + end_time=request.end_time, + status=request.status.value, + replay_data=request.replay_data, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + db.add(replay) + await db.flush() + + return GameReplayResponse.model_validate(replay) + + +@router.get("/replays", response_model=GameReplayListResponse) +async def get_game_replays( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + user_id: Optional[int] = None, + status_filter: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get game replays with optional filtering.""" + offset = (page - 1) * page_size + + # Build WHERE clause + conditions = [] + if user_id: + conditions.append(ReplayPlayer.user_id == user_id) + if status_filter: + conditions.append(GameReplay.status == status_filter) + + # Get total count + count_stmt = select(func.count()).select_from(GameReplay) + if conditions: + count_stmt = count_stmt.join(ReplayPlayer).where(*conditions) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + # Get replays + stmt = select(GameReplay) + if conditions: + stmt = stmt.join(ReplayPlayer).where(*conditions) + stmt = stmt.order_by(GameReplay.start_time.desc()).offset(offset).limit(page_size) + + result = await db.execute(stmt) + replays = result.scalars().all() + + return GameReplayListResponse( + replays=[GameReplayResponse.model_validate(r) for r in replays], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +@router.get("/replays/{replay_id}", response_model=GameReplayResponse) +async def get_game_replay( + replay_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get a specific game replay.""" + stmt = select(GameReplay).where(GameReplay.id == replay_id) + result = await db.execute(stmt) + replay = result.scalar_one_or_none() + + if not replay: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") + + return GameReplayResponse.model_validate(replay) + + +@router.patch("/replays/{replay_id}", response_model=GameReplayResponse) +async def update_game_replay( + replay_id: int, + request: GameReplayUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a game replay.""" + stmt = select(GameReplay).where(GameReplay.id == replay_id) + result = await db.execute(stmt) + replay = result.scalar_one_or_none() + + if not replay: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") + + update_data = request.model_dump(exclude_unset=True) + if "status" in update_data: + update_data["status"] = update_data["status"].value + update_data["updated_at"] = datetime.now(timezone.utc) + + stmt = ( + update(GameReplay) + .where(GameReplay.id == replay_id) + .values(**update_data) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(GameReplay).where(GameReplay.id == replay_id) + result = await db.execute(stmt) + updated_replay = result.scalar_one_or_none() + + return GameReplayResponse.model_validate(updated_replay) + + +@router.delete("/replays/{replay_id}", response_model=MessageResponse) +async def delete_game_replay( + replay_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a game replay.""" + stmt = select(GameReplay).where(GameReplay.id == replay_id) + result = await db.execute(stmt) + replay = result.scalar_one_or_none() + + if not replay: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") + + stmt = delete(GameReplay).where(GameReplay.id == replay_id) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Replay deleted successfully") + + +# ===== Replay Players ===== + +@router.post("/replays/{replay_id}/players", status_code=status.HTTP_201_CREATED) +async def add_replay_player( + replay_id: int, + user_id: int, + deck_id: Optional[int] = None, + position: Optional[int] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Add a player to a game replay.""" + # Verify replay exists + stmt = select(GameReplay).where(GameReplay.id == replay_id) + result = await db.execute(stmt) + replay = result.scalar_one_or_none() + + if not replay: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Replay not found") + + # Check if player already exists + player_check = ( + select(ReplayPlayer) + .where(ReplayPlayer.replay_id == replay_id, ReplayPlayer.user_id == user_id) + ) + player_result = await db.execute(player_check) + if player_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Player already in replay") + + player = ReplayPlayer( + replay_id=replay_id, + user_id=user_id, + deck_id=deck_id, + position=position, + created_at=datetime.now(timezone.utc) + ) + + db.add(player) + await db.flush() + + return {"message": "Player added to replay", "player_id": player.id} + + +@router.get("/replays/{replay_id}/players", response_model=List[dict]) +async def get_replay_players( + replay_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get all players in a game replay.""" + stmt = select(ReplayPlayer).where(ReplayPlayer.replay_id == replay_id) + result = await db.execute(stmt) + players = result.scalars().all() + + return [ + { + "id": p.id, + "user_id": p.user_id, + "deck_id": p.deck_id, + "position": p.position, + "won": p.won, + "lost": p.lost, + "concession": p.concession, + "turn_one": p.turn_one, + } + for p in players + ] + + +# ===== Game Outcomes ===== + +@router.post("/outcomes", response_model=GameOutcomeResponse, status_code=status.HTTP_201_CREATED) +async def create_game_outcome( + request: GameOutcomeCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a game outcome record.""" + outcome = GameOutcome( + user_id=int(current_user["user_id"]), + game_uuid=request.game_uuid, + outcome=request.outcome.value, + opponent_id=request.opponent_id, + format=request.format, + rating_before=request.rating_before, + rating_after=request.rating_after, + rating_change=request.rating_change, + created_at=datetime.now(timezone.utc) + ) + + db.add(outcome) + await db.flush() + + return GameOutcomeResponse.model_validate(outcome) + + +@router.get("/outcomes", response_model=GameOutcomeListResponse) +async def get_game_outcomes( + user_id: Optional[int] = None, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get game outcomes with optional filtering.""" + offset = (page - 1) * page_size + + stmt = select(GameOutcome) + if user_id: + stmt = stmt.where(GameOutcome.user_id == user_id) + stmt = stmt.order_by(GameOutcome.created_at.desc()).offset(offset).limit(page_size) + + result = await db.execute(stmt) + outcomes = result.scalars().all() + + count_stmt = select(func.count()).select_from(GameOutcome) + if user_id: + count_stmt = count_stmt.where(GameOutcome.user_id == user_id) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + return GameOutcomeListResponse( + outcomes=[GameOutcomeResponse.model_validate(o) for o in outcomes], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +# ===== User Statistics ===== + +@router.get("/statistics/{user_id}", response_model=UserStatisticsResponse) +async def get_user_statistics( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get user game statistics.""" + stmt = select(UserStatistics).where(UserStatistics.user_id == user_id) + result = await db.execute(stmt) + stats = result.scalar_one_or_none() + + if not stats: + # Create default statistics + stats = UserStatistics( + user_id=user_id, + total_games=0, + total_wins=0, + total_losses=0, + total_concessions=0, + win_rate=0.0, + current_streak=0, + best_streak=0, + average_rating=0.0, + updated_at=datetime.now(timezone.utc) + ) + db.add(stats) + await db.flush() + + return UserStatisticsResponse.model_validate(stats) + + +@router.post("/statistics/update", response_model=StatisticsUpdateResponse) +async def update_user_statistics( + user_id: int, + outcome: str, # 'WIN', 'LOSS', 'CONCESSION' + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update user statistics after a game.""" + stmt = select(UserStatistics).where(UserStatistics.user_id == user_id) + result = await db.execute(stmt) + stats = result.scalar_one_or_none() + + if not stats: + stats = UserStatistics( + user_id=user_id, + total_games=0, + total_wins=0, + total_losses=0, + total_concessions=0, + win_rate=0.0, + current_streak=0, + best_streak=0, + average_rating=0.0, + updated_at=datetime.now(timezone.utc) + ) + db.add(stats) + + # Update statistics + stats.total_games += 1 + stats.last_game_date = datetime.now(timezone.utc) + + if outcome == "WIN": + stats.total_wins += 1 + stats.current_streak = max(0, stats.current_streak) + 1 + if stats.current_streak > stats.best_streak: + stats.best_streak = stats.current_streak + elif outcome in ("LOSS", "CONCESSION"): + stats.total_losses += 1 + stats.total_concessions += 1 if outcome == "CONCESSION" else 0 + stats.current_streak = 0 + + # Calculate win rate + if stats.total_games > 0: + stats.win_rate = (stats.total_wins / stats.total_games) * 100 + + stats.updated_at = datetime.now(timezone.utc) + await db.flush() + + return StatisticsUpdateResponse( + user_id=stats.user_id, + total_games=stats.total_games, + total_wins=stats.total_wins, + total_losses=stats.total_losses, + win_rate=stats.win_rate, + current_streak=stats.current_streak, + updated_at=stats.updated_at + ) + + +# ===== Card Collection ===== + +@router.post("/collection", response_model=CardCollectionResponse, status_code=status.HTTP_201_CREATED) +async def add_card_to_collection( + request: CardCollectionCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Add a card to user collection.""" + user_id = int(current_user["user_id"]) + + # Check if card already exists in collection + check = ( + select(UserCardCollection) + .where( + UserCardCollection.user_id == user_id, + UserCardCollection.card_id == request.card_id, + UserCardCollection.is_foil == request.is_foil, + UserCardCollection.is_alt_art == request.is_alt_art, + ) + ) + check_result = await db.execute(check) + existing = check_result.scalar_one_or_none() + + if existing: + # Update quantity + stmt = ( + update(UserCardCollection) + .where(UserCardCollection.id == existing.id) + .values(quantity=existing.quantity + request.quantity) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(UserCardCollection).where(UserCardCollection.id == existing.id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + return CardCollectionResponse.model_validate(updated) + + card = UserCardCollection( + user_id=user_id, + card_id=request.card_id, + quantity=request.quantity, + condition=request.condition, + language=request.language, + is_foil=request.is_foil, + is_alt_art=request.is_alt_art, + acquired_date=request.acquired_date or datetime.now(timezone.utc), + acquisition_method=request.acquisition_method, + notes=request.notes, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + db.add(card) + await db.flush() + + return CardCollectionResponse.model_validate(card) + + +@router.get("/collection", response_model=CardCollectionListResponse) +async def get_user_collection( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + is_foil: Optional[bool] = None, + is_alt_art: Optional[bool] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get user's card collection.""" + user_id = int(current_user["user_id"]) + offset = (page - 1) * page_size + + conditions = [UserCardCollection.user_id == user_id] + if is_foil is not None: + conditions.append(UserCardCollection.is_foil == is_foil) + if is_alt_art is not None: + conditions.append(UserCardCollection.is_alt_art == is_alt_art) + + count_stmt = select(func.count()).select_from(UserCardCollection) + for cond in conditions: + count_stmt = count_stmt.where(cond) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + stmt = select(UserCardCollection) + for cond in conditions: + stmt = stmt.where(cond) + stmt = stmt.order_by(UserCardCollection.acquired_date.desc()).offset(offset).limit(page_size) + + result = await db.execute(stmt) + cards = result.scalars().all() + + return CardCollectionListResponse( + cards=[CardCollectionResponse.model_validate(c) for c in cards], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +@router.patch("/collection/{card_id}", response_model=CardCollectionResponse) +async def update_card_in_collection( + card_id: int, + request: CardCollectionUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a card in user collection.""" + user_id = int(current_user["user_id"]) + + stmt = select(UserCardCollection).where( + UserCardCollection.id == card_id, + UserCardCollection.user_id == user_id + ) + result = await db.execute(stmt) + card = result.scalar_one_or_none() + + if not card: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in collection") + + update_data = request.model_dump(exclude_unset=True) + update_data["updated_at"] = datetime.now(timezone.utc) + + stmt = ( + update(UserCardCollection) + .where(UserCardCollection.id == card_id) + .values(**update_data) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(UserCardCollection).where(UserCardCollection.id == card_id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + + return CardCollectionResponse.model_validate(updated) + + +@router.delete("/collection/{card_id}", response_model=MessageResponse) +async def remove_card_from_collection( + card_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Remove a card from user collection.""" + user_id = int(current_user["user_id"]) + + stmt = select(UserCardCollection).where( + UserCardCollection.id == card_id, + UserCardCollection.user_id == user_id + ) + result = await db.execute(stmt) + card = result.scalar_one_or_none() + + if not card: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in collection") + + stmt = delete(UserCardCollection).where(UserCardCollection.id == card_id) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Card removed from collection") + + +# ===== Wishlist ===== + +@router.post("/wishlist", response_model=WishlistResponse, status_code=status.HTTP_201_CREATED) +async def add_to_wishlist( + request: WishlistCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Add a card to wishlist.""" + user_id = int(current_user["user_id"]) + + # Check if already in wishlist + check = ( + select(CardWishlist) + .where(CardWishlist.user_id == user_id, CardWishlist.card_id == request.card_id) + ) + check_result = await db.execute(check) + if check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Card already in wishlist") + + item = CardWishlist( + user_id=user_id, + card_id=request.card_id, + max_price=request.max_price, + notes=request.notes, + created_at=datetime.now(timezone.utc) + ) + + db.add(item) + await db.flush() + + return WishlistResponse.model_validate(item) + + +@router.get("/wishlist", response_model=WishlistListResponse) +async def get_wishlist( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get user's wishlist.""" + user_id = int(current_user["user_id"]) + offset = (page - 1) * page_size + + count_stmt = select(func.count()).select_from(CardWishlist).where(CardWishlist.user_id == user_id) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + stmt = ( + select(CardWishlist) + .where(CardWishlist.user_id == user_id) + .order_by(CardWishlist.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await db.execute(stmt) + items = result.scalars().all() + + return WishlistListResponse( + items=[WishlistResponse.model_validate(i) for i in items], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +@router.patch("/wishlist/{item_id}", response_model=WishlistResponse) +async def update_wishlist_item( + item_id: int, + request: WishlistUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a wishlist item.""" + user_id = int(current_user["user_id"]) + + stmt = select(CardWishlist).where( + CardWishlist.id == item_id, + CardWishlist.user_id == user_id + ) + result = await db.execute(stmt) + item = result.scalar_one_or_none() + + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wishlist item not found") + + update_data = request.model_dump(exclude_unset=True) + + stmt = ( + update(CardWishlist) + .where(CardWishlist.id == item_id) + .values(**update_data) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(CardWishlist).where(CardWishlist.id == item_id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + + return WishlistResponse.model_validate(updated) + + +@router.delete("/wishlist/{item_id}", response_model=MessageResponse) +async def remove_from_wishlist( + item_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Remove a card from wishlist.""" + user_id = int(current_user["user_id"]) + + stmt = select(CardWishlist).where( + CardWishlist.id == item_id, + CardWishlist.user_id == user_id + ) + result = await db.execute(stmt) + item = result.scalar_one_or_none() + + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wishlist item not found") + + stmt = delete(CardWishlist).where(CardWishlist.id == item_id) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Removed from wishlist") + + +# ===== Groups ===== + +@router.post("/groups", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +async def create_group( + request: GroupCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new user group.""" + user_id = int(current_user["user_id"]) + + group = UserGroup( + name=request.name, + description=request.description, + owner_id=user_id, + is_public=request.is_public, + max_members=request.max_members, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + db.add(group) + await db.flush() + + # Add creator as owner + member = GroupMember( + group_id=group.id, + user_id=user_id, + role="OWNER", + joined_at=datetime.now(timezone.utc) + ) + db.add(member) + await db.flush() + + return GroupResponse.model_validate(group) + + +@router.get("/groups", response_model=GroupListResponse) +async def get_user_groups( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + is_public: Optional[bool] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get user's groups.""" + user_id = int(current_user["user_id"]) + offset = (page - 1) * page_size + + # Get groups where user is member or owner + member_groups = select(GroupMember.group_id).where(GroupMember.user_id == user_id) + + count_stmt = select(func.count()).select_from(UserGroup).where( + UserGroup.id.in_(member_groups) + ) + if is_public is not None: + count_stmt = count_stmt.where(UserGroup.is_public == is_public) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + stmt = ( + select(UserGroup) + .where(UserGroup.id.in_(member_groups)) + .order_by(UserGroup.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await db.execute(stmt) + groups = result.scalars().all() + + # Get member counts + group_ids = [g.id for g in groups] + member_counts = {} + if group_ids: + count_subquery = ( + select(GroupMember.group_id, func.count().label('count')) + .where(GroupMember.group_id.in_(group_ids)) + .group_by(GroupMember.group_id) + .subquery() + ) + count_stmt = select(count_subquery.c.group_id, count_subquery.c.count).where( + count_subquery.c.group_id.in_(group_ids) + ) + count_result = await db.execute(count_stmt) + member_counts = {row[0]: row[1] for row in count_result.fetchall()} + + return GroupListResponse( + groups=[ + GroupResponse( + **GroupResponse.model_validate(g).model_dump(), + member_count=member_counts.get(g.id, 0) + ) + for g in groups + ], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +@router.get("/groups/{group_id}", response_model=GroupResponse) +async def get_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get a specific group.""" + stmt = select(UserGroup).where(UserGroup.id == group_id) + result = await db.execute(stmt) + group = result.scalar_one_or_none() + + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + return GroupResponse.model_validate(group) + + +@router.patch("/groups/{group_id}", response_model=GroupResponse) +async def update_group( + group_id: int, + request: GroupUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a group.""" + user_id = int(current_user["user_id"]) + + # Verify user is owner + check = ( + select(GroupMember) + .where( + GroupMember.group_id == group_id, + GroupMember.user_id == user_id, + GroupMember.role == "OWNER" + ) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can update group") + + stmt = select(UserGroup).where(UserGroup.id == group_id) + result = await db.execute(stmt) + group = result.scalar_one_or_none() + + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + update_data = request.model_dump(exclude_unset=True) + update_data["updated_at"] = datetime.now(timezone.utc) + + stmt = ( + update(UserGroup) + .where(UserGroup.id == group_id) + .values(**update_data) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(UserGroup).where(UserGroup.id == group_id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + + return GroupResponse.model_validate(updated) + + +@router.delete("/groups/{group_id}", response_model=MessageResponse) +async def delete_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a group.""" + user_id = int(current_user["user_id"]) + + # Verify user is owner + check = ( + select(GroupMember) + .where( + GroupMember.group_id == group_id, + GroupMember.user_id == user_id, + GroupMember.role == "OWNER" + ) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can delete group") + + stmt = delete(UserGroup).where(UserGroup.id == group_id) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Group deleted successfully") + + +# ===== Group Members ===== + +@router.post("/groups/{group_id}/members", status_code=status.HTTP_201_CREATED) +async def add_group_member( + group_id: int, + request: GroupMemberCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Add a member to a group.""" + user_id = int(current_user["user_id"]) + + # Verify user is admin or owner + check = ( + select(GroupMember) + .where( + GroupMember.group_id == group_id, + GroupMember.user_id == user_id, + GroupMember.role.in_(["OWNER", "ADMIN"]) + ) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") + + # Check if already a member + member_check = ( + select(GroupMember) + .where(GroupMember.group_id == group_id, GroupMember.user_id == request.user_id) + ) + member_result = await db.execute(member_check) + if member_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member") + + member = GroupMember( + group_id=group_id, + user_id=request.user_id, + role=request.role.value, + joined_at=datetime.now(timezone.utc) + ) + + db.add(member) + await db.flush() + + return {"message": "Member added", "member_id": member.id} + + +@router.patch("/groups/{group_id}/members/{member_id}", status_model=GroupMemberUpdate) +async def update_group_member( + group_id: int, + member_id: int, + request: GroupMemberUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a group member's role.""" + user_id = int(current_user["user_id"]) + + # Verify user is owner + check = ( + select(GroupMember) + .where( + GroupMember.group_id == group_id, + GroupMember.user_id == user_id, + GroupMember.role == "OWNER" + ) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owner can update roles") + + stmt = select(GroupMember).where(GroupMember.id == member_id) + result = await db.execute(stmt) + member = result.scalar_one_or_none() + + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + + stmt = ( + update(GroupMember) + .where(GroupMember.id == member_id) + .values(role=request.role.value) + ) + await db.execute(stmt) + await db.flush() + + return {"message": "Member role updated"} + + +@router.delete("/groups/{group_id}/members/{member_id}", response_model=MessageResponse) +async def remove_group_member( + group_id: int, + member_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Remove a member from a group.""" + user_id = int(current_user["user_id"]) + + # Verify user is admin or owner + check = ( + select(GroupMember) + .where( + GroupMember.group_id == group_id, + GroupMember.user_id == user_id, + GroupMember.role.in_(["OWNER", "ADMIN"]) + ) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") + + stmt = select(GroupMember).where(GroupMember.id == member_id) + result = await db.execute(stmt) + member = result.scalar_one_or_none() + + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + + stmt = delete(GroupMember).where(GroupMember.id == member_id) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Member removed from group") + + +# ===== Group Chat Messages ===== + +@router.post("/groups/{group_id}/messages", response_model=GroupChatMessageResponse, status_code=status.HTTP_201_CREATED) +async def send_group_message( + group_id: int, + request: GroupChatMessageCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Send a message in a group chat.""" + user_id = int(current_user["user_id"]) + + # Verify user is a member + member_check = ( + select(GroupMember) + .where(GroupMember.group_id == group_id, GroupMember.user_id == user_id) + ) + member_result = await db.execute(member_check) + if not member_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a group member") + + # Verify group exists + group_check = select(UserGroup).where(UserGroup.id == group_id) + group_result = await db.execute(group_check) + if not group_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + message = GroupChatMessage( + group_id=group_id, + sender_id=user_id, + message=request.message, + created_at=datetime.now(timezone.utc) + ) + + db.add(message) + await db.flush() + + # Get sender username + user_check = select(User).where(User.id == user_id) + user_result = await db.execute(user_check) + sender = user_result.scalar_one_or_none() + + return GroupChatMessageResponse( + id=message.id, + group_id=message.group_id, + sender_id=message.sender_id, + sender_username=sender.username if sender else None, + message=message.message, + created_at=message.created_at + ) + + +@router.get("/groups/{group_id}/messages", response_model=GroupChatMessageListResponse) +async def get_group_messages( + group_id: int, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get messages in a group chat.""" + offset = (page - 1) * page_size + + # Verify user is a member + member_check = ( + select(GroupMember) + .where(GroupMember.group_id == group_id, GroupMember.user_id == int(current_user["user_id"])) + ) + member_result = await db.execute(member_check) + if not member_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a group member") + + count_stmt = select(func.count()).select_from(GroupChatMessage).where(GroupChatMessage.group_id == group_id) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + stmt = ( + select(GroupChatMessage) + .where(GroupChatMessage.group_id == group_id) + .order_by(GroupChatMessage.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await db.execute(stmt) + messages = result.scalars().all() + + # Get sender usernames + sender_ids = [m.sender_id for m in messages] + sender_names = {} + if sender_ids: + user_stmt = select(User.id, User.username).where(User.id.in_(sender_ids)) + user_result = await db.execute(user_stmt) + sender_names = {row[0]: row[1] for row in user_result.fetchall()} + + return GroupChatMessageListResponse( + messages=[ + GroupChatMessageResponse( + id=m.id, + group_id=m.group_id, + sender_id=m.sender_id, + sender_username=sender_names.get(m.sender_id), + message=m.message, + created_at=m.created_at + ) + for m in messages + ], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +# ===== Networks ===== + +@router.post("/networks", response_model=NetworkResponse, status_code=status.HTTP_201_CREATED) +async def create_network( + request: NetworkCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new user network.""" + user_id = int(current_user["user_id"]) + + network = UserNetwork( + name=request.name, + description=request.description, + creator_id=user_id, + is_public=request.is_public, + created_at=datetime.now(timezone.utc) + ) + + db.add(network) + await db.flush() + + # Add creator as owner + member = NetworkMember( + network_id=network.id, + user_id=user_id, + role="OWNER", + joined_at=datetime.now(timezone.utc) + ) + db.add(member) + await db.flush() + + return NetworkResponse.model_validate(network) + + +@router.get("/networks", response_model=NetworkListResponse) +async def get_user_networks( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get user's networks.""" + user_id = int(current_user["user_id"]) + offset = (page - 1) * page_size + + member_networks = select(NetworkMember.network_id).where(NetworkMember.user_id == user_id) + + count_stmt = select(func.count()).select_from(UserNetwork).where( + UserNetwork.id.in_(member_networks) + ) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + stmt = ( + select(UserNetwork) + .where(UserNetwork.id.in_(member_networks)) + .order_by(UserNetwork.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await db.execute(stmt) + networks = result.scalars().all() + + # Get member counts + network_ids = [n.id for n in networks] + member_counts = {} + if network_ids: + count_subquery = ( + select(NetworkMember.network_id, func.count().label('count')) + .where(NetworkMember.network_id.in_(network_ids)) + .group_by(NetworkMember.network_id) + .subquery() + ) + count_stmt = select(count_subquery.c.network_id, count_subquery.c.count).where( + count_subquery.c.network_id.in_(network_ids) + ) + count_result = await db.execute(count_stmt) + member_counts = {row[0]: row[1] for row in count_result.fetchall()} + + return NetworkListResponse( + networks=[ + NetworkResponse( + **NetworkResponse.model_validate(n).model_dump(), + member_count=member_counts.get(n.id, 0) + ) + for n in networks + ], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) + + +@router.get("/networks/{network_id}", response_model=NetworkResponse) +async def get_network( + network_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get a specific network.""" + stmt = select(UserNetwork).where(UserNetwork.id == network_id) + result = await db.execute(stmt) + network = result.scalar_one_or_none() + + if not network: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Network not found") + + return NetworkResponse.model_validate(network) + + +@router.patch("/networks/{network_id}", response_model=NetworkResponse) +async def update_network( + network_id: int, + request: NetworkUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a network.""" + user_id = int(current_user["user_id"]) + + # Verify user is creator + check = ( + select(UserNetwork) + .where(UserNetwork.id == network_id, UserNetwork.creator_id == user_id) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can update network") + + stmt = select(UserNetwork).where(UserNetwork.id == network_id) + result = await db.execute(stmt) + network = result.scalar_one_or_none() + + if not network: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Network not found") + + update_data = request.model_dump(exclude_unset=True) + + stmt = ( + update(UserNetwork) + .where(UserNetwork.id == network_id) + .values(**update_data) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(UserNetwork).where(UserNetwork.id == network_id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + + return NetworkResponse.model_validate(updated) + + +@router.delete("/networks/{network_id}", response_model=MessageResponse) +async def delete_network( + network_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a network.""" + user_id = int(current_user["user_id"]) + + # Verify user is creator + check = ( + select(UserNetwork) + .where(UserNetwork.id == network_id, UserNetwork.creator_id == user_id) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can delete network") + + stmt = delete(UserNetwork).where(UserNetwork.id == network_id) + await db.execute(stmt) + await db.flush() + + return MessageResponse(message="Network deleted successfully") + + +# ===== Network Members ===== + +@router.post("/networks/{network_id}/members", status_code=status.HTTP_201_CREATED) +async def add_network_member( + network_id: int, + request: NetworkMemberCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Add a member to a network.""" + user_id = int(current_user["user_id"]) + + # Verify user is admin or owner + check = ( + select(NetworkMember) + .where( + NetworkMember.network_id == network_id, + NetworkMember.user_id == user_id, + NetworkMember.role.in_(["OWNER", "ADMIN"]) + ) + ) + check_result = await db.execute(check) + if not check_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") + + # Check if already a member + member_check = ( + select(NetworkMember) + .where(NetworkMember.network_id == network_id, NetworkMember.user_id == request.user_id) + ) + member_result = await db.execute(member_check) + if member_result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member") + + member = NetworkMember( + network_id=network_id, + user_id=request.user_id, + role=request.role.value, + joined_at=datetime.now(timezone.utc) + ) + + db.add(member) + await db.flush() + + return {"message": "Member added", "member_id": member.id} + + +# ===== User Preferences ===== + +@router.get("/preferences", response_model=UserPreferenceResponse) +async def get_user_preferences( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get user preferences.""" + user_id = int(current_user["user_id"]) + + stmt = select(UserPreference).where(UserPreference.user_id == user_id) + result = await db.execute(stmt) + prefs = result.scalar_one_or_none() + + if not prefs: + # Create default preferences + prefs = UserPreference( + user_id=user_id, + theme="light", + notifications_enabled=True, + email_notifications=True, + auto_save_decks=True, + default_format="standard", + language="EN", + updated_at=datetime.now(timezone.utc) + ) + db.add(prefs) + await db.flush() + + return UserPreferenceResponse.model_validate(prefs) + + +@router.patch("/preferences", response_model=UserPreferenceResponse) +async def update_user_preferences( + request: UserPreferenceUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update user preferences.""" + user_id = int(current_user["user_id"]) + + stmt = select(UserPreference).where(UserPreference.user_id == user_id) + result = await db.execute(stmt) + prefs = result.scalar_one_or_none() + + if not prefs: + # Create default preferences first + prefs = UserPreference( + user_id=user_id, + updated_at=datetime.now(timezone.utc) + ) + db.add(prefs) + await db.flush() + + update_data = request.model_dump(exclude_unset=True) + update_data["updated_at"] = datetime.now(timezone.utc) + + stmt = ( + update(UserPreference) + .where(UserPreference.user_id == user_id) + .values(**update_data) + ) + await db.execute(stmt) + await db.flush() + + stmt = select(UserPreference).where(UserPreference.user_id == user_id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + + return UserPreferenceResponse.model_validate(updated) + + +# ===== Activity Log ===== + +@router.get("/activity", response_model=ActivityLogListResponse) +async def get_activity_log( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + activity_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get user activity log.""" + user_id = int(current_user["user_id"]) + offset = (page - 1) * page_size + + conditions = [UserActivityLog.user_id == user_id] + if activity_type: + conditions.append(UserActivityLog.activity_type == activity_type) + + count_stmt = select(func.count()).select_from(UserActivityLog) + for cond in conditions: + count_stmt = count_stmt.where(cond) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + stmt = select(UserActivityLog) + for cond in conditions: + stmt = stmt.where(cond) + stmt = stmt.order_by(UserActivityLog.created_at.desc()).offset(offset).limit(page_size) + + result = await db.execute(stmt) + entries = result.scalars().all() + + return ActivityLogListResponse( + entries=[ActivityLogEntry.model_validate(e) for e in entries], + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size + ) diff --git a/backend/app/schemas/user_data_schemas.py b/backend/app/schemas/user_data_schemas.py new file mode 100644 index 0000000..39550cd --- /dev/null +++ b/backend/app/schemas/user_data_schemas.py @@ -0,0 +1,526 @@ +""" +Pydantic schemas for user data features. + +Covers sessions, decks, replays, cards, groups, networks, preferences, and activity logs. +""" +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum + + +# ===== Enum Types ===== + +class DeckVersionStatus(str, Enum): + DRAFT = "DRAFT" + FINAL = "FINAL" + ARCHIVED = "ARCHIVED" + + +class GameReplayStatus(str, Enum): + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + + +class GameOutcomeType(str, Enum): + WIN = "WIN" + LOSS = "LOSS" + CONCESSION = "CONCESSION" + DISCONNECT = "DISCONNECT" + + +class GroupMemberRole(str, Enum): + OWNER = "OWNER" + ADMIN = "ADMIN" + MEMBER = "MEMBER" + + +class NetworkMemberRole(str, Enum): + OWNER = "OWNER" + ADMIN = "ADMIN" + MEMBER = "MEMBER" + + +class UserPreferenceTheme(str, Enum): + LIGHT = "light" + DARK = "dark" + SYSTEM = "system" + + +class ActivityType(str, Enum): + LOGIN = "LOGIN" + LOGOUT = "LOGOUT" + DECK_EDIT = "DECK_EDIT" + GAME_PLAYED = "GAME_PLAYED" + CARD_ACQUIRED = "CARD_ACQUIRED" + CARD_TRADED = "CARD_TRADED" + GROUP_CREATED = "GROUP_CREATED" + GROUP_JOINED = "GROUP_JOINED" + + +# ===== Session Schemas ===== + +class SessionResponse(BaseModel): + """User session response.""" + id: int + user_id: int + ip_address: Optional[str] = None + user_agent: Optional[str] = None + created_at: datetime + expires_at: datetime + is_active: bool + + class Config: + from_attributes = True + + +class SessionCleanupResponse(BaseModel): + """Response after cleaning expired sessions.""" + cleaned_count: int + message: str + + +# ===== Deck Version Schemas ===== + +class DeckVersionCreate(BaseModel): + """Deck version creation request.""" + content: str = Field(..., min_length=1) + status: DeckVersionStatus = DeckVersionStatus.DRAFT + comment: Optional[str] = None + + +class DeckVersionUpdate(BaseModel): + """Deck version update request.""" + content: Optional[str] = None + status: Optional[DeckVersionStatus] = None + comment: Optional[str] = None + + +class DeckVersionResponse(BaseModel): + """Deck version response.""" + id: int + deck_id: int + version_number: int + content: str + status: str + comment: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class DeckVersionListResponse(BaseModel): + """List of deck versions.""" + versions: List[DeckVersionResponse] + total: int + + +# ===== Game Replay Schemas ===== + +class GameReplayCreate(BaseModel): + """Game replay creation request.""" + game_uuid: str = Field(..., min_length=36, max_length=36) + room_id: Optional[int] = None + game_type: Optional[str] = None + format: Optional[str] = None + duration_seconds: Optional[int] = None + start_time: datetime + end_time: Optional[datetime] = None + status: GameReplayStatus = GameReplayStatus.IN_PROGRESS + replay_data: Optional[Dict[str, Any]] = None + + +class GameReplayUpdate(BaseModel): + """Game replay update request.""" + room_id: Optional[int] = None + game_type: Optional[str] = None + format: Optional[str] = None + duration_seconds: Optional[int] = None + end_time: Optional[datetime] = None + status: Optional[GameReplayStatus] = None + replay_data: Optional[Dict[str, Any]] = None + + +class GameReplayResponse(BaseModel): + """Game replay response.""" + id: int + game_uuid: str + room_id: Optional[int] + game_type: Optional[str] + format: Optional[str] + duration_seconds: Optional[int] + start_time: datetime + end_time: Optional[datetime] + status: str + replay_data: Optional[Dict[str, Any]] + created_at: datetime + updated_at: datetime + players: List[Dict[str, Any]] = [] + + class Config: + from_attributes = True + + +class GameReplayListResponse(BaseModel): + """List of game replays.""" + replays: List[GameReplayResponse] + total: int + page: int + page_size: int + total_pages: int + + +# ===== Game Outcome Schemas ===== + +class GameOutcomeCreate(BaseModel): + """Game outcome creation request.""" + game_uuid: str + outcome: GameOutcomeType + opponent_id: Optional[int] = None + format: Optional[str] = None + rating_before: Optional[int] = None + rating_after: Optional[int] = None + rating_change: Optional[int] = None + + +class GameOutcomeResponse(BaseModel): + """Game outcome response.""" + id: int + user_id: int + game_uuid: str + outcome: str + opponent_id: Optional[int] + format: Optional[str] + rating_before: Optional[int] + rating_after: Optional[int] + rating_change: Optional[int] + created_at: datetime + + class Config: + from_attributes = True + + +class GameOutcomeListResponse(BaseModel): + """List of game outcomes.""" + outcomes: List[GameOutcomeResponse] + total: int + + +# ===== User Statistics Schemas ===== + +class UserStatisticsResponse(BaseModel): + """User statistics summary response.""" + user_id: int + total_games: int + total_wins: int + total_losses: int + total_concessions: int + win_rate: float + current_streak: int + best_streak: int + average_rating: float + last_game_date: Optional[datetime] + updated_at: datetime + + class Config: + from_attributes = True + + +class StatisticsUpdateResponse(BaseModel): + """Response after updating statistics.""" + user_id: int + total_games: int + total_wins: int + total_losses: int + win_rate: float + current_streak: int + updated_at: datetime + + +# ===== Card Collection Schemas ===== + +class CardCollectionCreate(BaseModel): + """Card collection item creation request.""" + card_id: int + quantity: int = Field(1, ge=1) + condition: str = Field("NEAR_MINT", max_length=20) + language: str = Field("EN", max_length=5) + is_foil: bool = False + is_alt_art: bool = False + acquired_date: Optional[datetime] = None + acquisition_method: Optional[str] = None + notes: Optional[str] = None + + +class CardCollectionUpdate(BaseModel): + """Card collection item update request.""" + quantity: Optional[int] = None + condition: Optional[str] = None + language: Optional[str] = None + is_foil: Optional[bool] = None + is_alt_art: Optional[bool] = None + acquired_date: Optional[datetime] = None + acquisition_method: Optional[str] = None + notes: Optional[str] = None + + +class CardCollectionResponse(BaseModel): + """Card collection item response.""" + id: int + user_id: int + card_id: int + quantity: int + condition: str + language: str + is_foil: bool + is_alt_art: bool + acquired_date: Optional[datetime] + acquisition_method: Optional[str] + notes: Optional[str] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class CardCollectionListResponse(BaseModel): + """List of user card collection.""" + cards: List[CardCollectionResponse] + total: int + page: int + page_size: int + total_pages: int + + +# ===== Wishlist Schemas ===== + +class WishlistCreate(BaseModel): + """Wishlist item creation request.""" + card_id: int + max_price: Optional[float] = None + notes: Optional[str] = None + + +class WishlistUpdate(BaseModel): + """Wishlist item update request.""" + max_price: Optional[float] = None + notes: Optional[str] = None + + +class WishlistResponse(BaseModel): + """Wishlist item response.""" + id: int + user_id: int + card_id: int + max_price: Optional[float] + notes: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class WishlistListResponse(BaseModel): + """List of wishlist items.""" + items: List[WishlistResponse] + total: int + + +# ===== Group Schemas ===== + +class GroupCreate(BaseModel): + """User group creation request.""" + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + is_public: bool = True + max_members: int = Field(50, ge=2, le=500) + + +class GroupUpdate(BaseModel): + """User group update request.""" + name: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = None + max_members: Optional[int] = None + + +class GroupMemberCreate(BaseModel): + """Group member addition request.""" + user_id: int + role: GroupMemberRole = GroupMemberRole.MEMBER + + +class GroupMemberUpdate(BaseModel): + """Group member role update request.""" + role: GroupMemberRole + + +class GroupMemberRemove(BaseModel): + """Group member removal request.""" + user_id: int + + +class GroupResponse(BaseModel): + """User group response.""" + id: int + name: str + description: Optional[str] + owner_id: int + is_public: bool + max_members: int + created_at: datetime + updated_at: datetime + member_count: int = 0 + is_member: bool = False + + class Config: + from_attributes = True + + +class GroupListResponse(BaseModel): + """List of user groups.""" + groups: List[GroupResponse] + total: int + + +class GroupChatMessageCreate(BaseModel): + """Group chat message creation request.""" + message: str = Field(..., min_length=1, max_length=2000) + + +class GroupChatMessageResponse(BaseModel): + """Group chat message response.""" + id: int + group_id: int + sender_id: int + sender_username: Optional[str] = None + message: str + created_at: datetime + + class Config: + from_attributes = True + + +class GroupChatMessageListResponse(BaseModel): + """List of group chat messages.""" + messages: List[GroupChatMessageResponse] + total: int + page: int + page_size: int + total_pages: int + + +# ===== Network Schemas ===== + +class NetworkCreate(BaseModel): + """User network creation request.""" + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + is_public: bool = True + + +class NetworkUpdate(BaseModel): + """User network update request.""" + name: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = None + + +class NetworkMemberCreate(BaseModel): + """Network member addition request.""" + user_id: int + role: NetworkMemberRole = NetworkMemberRole.MEMBER + + +class NetworkResponse(BaseModel): + """User network response.""" + id: int + name: str + description: Optional[str] + creator_id: int + is_public: bool + created_at: datetime + member_count: int = 0 + is_member: bool = False + + class Config: + from_attributes = True + + +class NetworkListResponse(BaseModel): + """List of user networks.""" + networks: List[NetworkResponse] + total: int + + +# ===== Preference Schemas ===== + +class UserPreferenceUpdate(BaseModel): + """User preference update request.""" + theme: Optional[UserPreferenceTheme] = None + notifications_enabled: Optional[bool] = None + email_notifications: Optional[bool] = None + auto_save_decks: Optional[bool] = None + default_format: Optional[str] = None + language: Optional[str] = None + + +class UserPreferenceResponse(BaseModel): + """User preference response.""" + user_id: int + theme: str + notifications_enabled: bool + email_notifications: bool + auto_save_decks: bool + default_format: str + language: str + updated_at: datetime + + class Config: + from_attributes = True + + +# ===== Activity Log Schemas ===== + +class ActivityLogEntry(BaseModel): + """Activity log entry.""" + id: int + user_id: int + activity_type: str + activity_data: Optional[Dict[str, Any]] + ip_address: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class ActivityLogListResponse(BaseModel): + """List of activity log entries.""" + entries: List[ActivityLogEntry] + total: int + page: int + page_size: int + total_pages: int + + +# ===== Generic Response Schemas ===== + +class MessageResponse(BaseModel): + """Generic message response.""" + message: str + + +class CountResponse(BaseModel): + """Generic count response.""" + count: int + + +class ErrorDetail(BaseModel): + """Error detail.""" + error: str + detail: str diff --git a/backend/scripts/run_migrations.sh b/backend/scripts/run_migrations.sh new file mode 100644 index 0000000..7801b52 --- /dev/null +++ b/backend/scripts/run_migrations.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Run Alembic migrations with database connectivity check + +set -e + +echo "Running Alembic migrations..." + +# Check if database is reachable +echo "Checking database connectivity..." +until python -c " +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine + +async def check(): + engine = create_async_engine('postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline') + async with engine.connect() as conn: + await conn.execute(sqlalchemy.text('SELECT 1')) + await engine.dispose() + print('Database connection successful') + +import sqlalchemy +asyncio.run(check()) +" 2>/dev/null; do + echo "Waiting for database to be ready..." + sleep 2 +done + +# Run migrations +echo "Applying migrations..." +alembic upgrade head + +echo "Migrations completed successfully!"