diff --git a/.clinerules/cline_rules.md b/.clinerules/cline_rules.md index c3652f86..3e6c08a3 100644 --- a/.clinerules/cline_rules.md +++ b/.clinerules/cline_rules.md @@ -33,6 +33,19 @@ ### CRITICAL DOCUMENTATION RULE - CRITICAL: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. Your edits to that file must be comprehensive and include all relevant details. If the file does not exist, you must create it and assume it is for a NextJS frontend. - CRITICAL: It is MANDATORY to include any types that need to be added to the frontend in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/api.ts. Full type safety. +- IT IS MANDATORY that api calls include a trailing forward slash. See example below. The forward slash may only be omitted if the end of the endpoint is a query parameter such as ``/companies/${query ? `?${query}` : ''}` or `/map/locations/${createQuery(params)}`. + Example: + + ``` + async updateAvatar(formData: FormData): Promise { + return makeRequest('/auth/user/avatar/', { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + },` + ``` + - The types-api.ts file should import the types file as such `@/types/api` and not from any other location. - CRITICAL: It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/types.ts. Full type safety. ### CRITICAL DATA RULE diff --git a/.clinerules/django-moderation-integration.md b/.clinerules/django-moderation-integration.md deleted file mode 100644 index b49e78cf..00000000 --- a/.clinerules/django-moderation-integration.md +++ /dev/null @@ -1,38 +0,0 @@ -## Brief overview -Guidelines for integrating new Django moderation systems while preserving existing functionality, based on ThrillWiki project patterns. These rules ensure backward compatibility and proper integration when extending moderation capabilities. - -## Integration approach -- Always preserve existing models and functionality when adding new moderation features -- Use comprehensive model integration rather than replacement - combine original and new models in single files -- Maintain existing method signatures and property aliases for backward compatibility -- Fix field name mismatches systematically across services, selectors, and related components - -## Django model patterns -- All models must inherit from TrackedModel base class and use pghistory for change tracking -- Use proper Meta class inheritance: `class Meta(TrackedModel.Meta):` -- Maintain comprehensive business logic methods on models (approve, reject, escalate) -- Include property aliases for backward compatibility when field names change - -## Service and selector updates -- Update services and selectors to match restored model field names systematically -- Use proper field references: `user` instead of `submitted_by`, `created_at` instead of `submitted_at` -- Maintain transaction safety with `select_for_update()` in services -- Keep comprehensive error handling and validation in service methods - -## Migration strategy -- Create migrations incrementally and handle field changes with proper defaults -- Test Django checks after each major integration step -- Apply migrations immediately after creation to verify database compatibility -- Handle nullable field changes with appropriate migration prompts - -## Documentation requirements -- Update docs/types-api.ts with complete TypeScript interface definitions -- Update docs/lib-api.ts with comprehensive API client implementation -- Ensure full type safety and proper error handling in TypeScript clients -- Document all new API endpoints and integration patterns - -## Testing and validation -- Run Django system checks after each integration step -- Verify existing functionality still works after adding new features -- Test both original workflow and new moderation system functionality -- Confirm database migrations apply successfully without errors diff --git a/.clinerules/mandatory-compliance-enforcement.md b/.clinerules/mandatory-compliance-enforcement.md new file mode 100644 index 00000000..f4c2fc56 --- /dev/null +++ b/.clinerules/mandatory-compliance-enforcement.md @@ -0,0 +1,32 @@ +## Brief overview +This rule file establishes strict compliance enforcement protocols for mandatory development standards. These guidelines are project-specific and stem from critical rule violations that compromised system integrity. All rules marked as "MANDATORY" in project documentation must be followed without exception. + +## Rule compliance verification +- Always read and review ALL .clinerules files before making any code changes +- Verify compliance with mandatory formatting requirements before committing +- Double-check work against explicitly stated project standards +- Never assume exceptions to rules marked as "MANDATORY" + +## API documentation standards +- All API endpoints MUST include trailing forward slashes unless ending with query parameters +- Follow the exact format specified in .clinerules for API endpoint documentation +- Validate all endpoint URLs against the mandatory trailing slash rule +- Ensure consistency across all API documentation files + +## Quality assurance protocols +- Perform systematic review of all changes against project rules +- Validate that modifications comply with architectural standards +- Check for systematic patterns that might indicate rule violations +- Implement self-review processes before submitting any work + +## Accountability measures +- Take full responsibility for rule violations without excuses +- Acknowledge when mandatory standards have been compromised +- Accept consequences for systematic non-compliance +- Demonstrate commitment to following established project standards + +## Documentation integrity +- Maintain accuracy and compliance in all technical documentation +- Ensure API documentation matches backend URL routing expectations +- Preserve system architecture integrity through compliant documentation +- Follow project-specific formatting requirements exactly as specified diff --git a/backend/apps/accounts/migrations/0008_remove_first_last_name_fields.py b/backend/apps/accounts/migrations/0008_remove_first_last_name_fields.py new file mode 100644 index 00000000..bed79422 --- /dev/null +++ b/backend/apps/accounts/migrations/0008_remove_first_last_name_fields.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.5 on 2025-08-29 21:32 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0007_add_display_name_to_user"), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="user", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="user", + name="update_update", + ), + migrations.RemoveField( + model_name="user", + name="first_name", + ), + migrations.RemoveField( + model_name="user", + name="last_name", + ), + migrations.RemoveField( + model_name="userevent", + name="first_name", + ), + migrations.RemoveField( + model_name="userevent", + name="last_name", + ), + pgtrigger.migrations.AddTrigger( + model_name="user", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;', + hash="1ffd9209b0e1949c05de2548585cda9179288b68", + operation="INSERT", + pgid="pgtrigger_insert_insert_3867c", + table="accounts_user", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="user", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;', + hash="e5f0a1acc20a9aad226004bc93ca8dbc3511052f", + operation="UPDATE", + pgid="pgtrigger_update_update_0e890", + table="accounts_user", + when="AFTER", + ), + ), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 97ca297f..53f64592 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -44,6 +44,10 @@ class User(AbstractUser): FRIENDS = "friends", _("Friends Only") PRIVATE = "private", _("Private") + # Override inherited fields to remove them + first_name = None + last_name = None + # Read-only ID user_id = models.CharField( max_length=10, @@ -179,7 +183,10 @@ class UserProfile(models.Model): """ if self.avatar: # Return Cloudflare Images URL with avatar variant - return self.avatar.url_variant("avatar") + base_url = self.avatar.url + if '/public' in base_url: + return base_url.replace('/public', '/avatar') + return base_url # Generate default letter-based avatar using first letter of username first_letter = self.user.username[0].upper() if self.user.username else "U" @@ -191,11 +198,20 @@ class UserProfile(models.Model): Return avatar variants for different use cases """ if self.avatar: - return { - "thumbnail": self.avatar.url_variant("thumbnail"), - "avatar": self.avatar.url_variant("avatar"), - "large": self.avatar.url_variant("large"), - } + base_url = self.avatar.url + if '/public' in base_url: + return { + "thumbnail": base_url.replace('/public', '/thumbnail'), + "avatar": base_url.replace('/public', '/avatar'), + "large": base_url.replace('/public', '/large'), + } + else: + # If no variant in URL, return the same URL for all variants + return { + "thumbnail": base_url, + "avatar": base_url, + "large": base_url, + } # For default avatars, return the same URL for all variants default_url = self.get_avatar_url() diff --git a/backend/apps/accounts/selectors.py b/backend/apps/accounts/selectors.py index fda8718f..72c03ea1 100644 --- a/backend/apps/accounts/selectors.py +++ b/backend/apps/accounts/selectors.py @@ -176,8 +176,7 @@ def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet: """ return User.objects.filter( Q(username__icontains=query) - | Q(first_name__icontains=query) - | Q(last_name__icontains=query), + | Q(display_name__icontains=query), is_active=True, ).order_by("username")[:limit] diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 295492d1..296e8c71 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -19,6 +19,7 @@ class UserSerializer(serializers.ModelSerializer): """ avatar_url = serializers.SerializerMethodField() + display_name = serializers.SerializerMethodField() class Meta: model = User @@ -26,8 +27,7 @@ class UserSerializer(serializers.ModelSerializer): "id", "username", "email", - "first_name", - "last_name", + "display_name", "date_joined", "is_active", "avatar_url", @@ -40,6 +40,10 @@ class UserSerializer(serializers.ModelSerializer): return obj.profile.avatar.url return None + def get_display_name(self, obj) -> str: + """Get user display name""" + return obj.get_display_name() + class LoginSerializer(serializers.Serializer): """ @@ -82,14 +86,14 @@ class SignupSerializer(serializers.ModelSerializer): fields = [ "username", "email", - "first_name", - "last_name", + "display_name", "password", "password_confirm", ] extra_kwargs = { "password": {"write_only": True}, "email": {"required": True}, + "display_name": {"required": True}, } def validate_email(self, value): diff --git a/backend/apps/accounts/services.py b/backend/apps/accounts/services.py index 1f4f9a0e..ca530d17 100644 --- a/backend/apps/accounts/services.py +++ b/backend/apps/accounts/services.py @@ -28,8 +28,6 @@ class UserDeletionService: username=cls.DELETED_USER_USERNAME, defaults={ "email": cls.DELETED_USER_EMAIL, - "first_name": "", - "last_name": "", "is_active": False, "is_staff": False, "is_superuser": False, @@ -177,7 +175,11 @@ class UserDeletionService: return False, "Cannot delete the system deleted user placeholder" if user.is_superuser: - return False, "Cannot delete superuser accounts" + return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first." + + # Check if user has critical admin role + if user.role == User.Roles.ADMIN and user.is_staff: + return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator." # Add any other business rules here diff --git a/backend/apps/accounts/services/__init__.py b/backend/apps/accounts/services/__init__.py new file mode 100644 index 00000000..0134fad4 --- /dev/null +++ b/backend/apps/accounts/services/__init__.py @@ -0,0 +1,11 @@ +""" +Accounts Services Package + +This package contains business logic services for account management, +including social provider management, user authentication, and profile services. +""" + +from .social_provider_service import SocialProviderService +from .user_deletion_service import UserDeletionService + +__all__ = ['SocialProviderService', 'UserDeletionService'] diff --git a/backend/apps/accounts/services/social_provider_service.py b/backend/apps/accounts/services/social_provider_service.py new file mode 100644 index 00000000..5565e81e --- /dev/null +++ b/backend/apps/accounts/services/social_provider_service.py @@ -0,0 +1,258 @@ +""" +Social Provider Management Service + +This service handles the business logic for connecting and disconnecting +social authentication providers while ensuring users never lock themselves +out of their accounts. +""" + +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from allauth.socialaccount.models import SocialAccount, SocialApp +from allauth.socialaccount.providers import registry +from django.contrib.sites.shortcuts import get_current_site +from django.http import HttpRequest +import logging + +if TYPE_CHECKING: + from apps.accounts.models import User +else: + User = get_user_model() + +logger = logging.getLogger(__name__) + + +class SocialProviderService: + """Service for managing social provider connections.""" + + @staticmethod + def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]: + """ + Check if a user can safely disconnect a social provider. + + Args: + user: The user attempting to disconnect + provider: The provider to disconnect (e.g., 'google', 'discord') + + Returns: + Tuple of (can_disconnect: bool, reason: str) + """ + try: + # Count remaining social accounts after disconnection + remaining_social_accounts = user.socialaccount_set.exclude( + provider=provider + ).count() + + # Check if user has email/password auth + has_password_auth = ( + user.email and + user.has_usable_password() and + bool(user.password) # Not empty/unusable + ) + + # Allow disconnection only if alternative auth exists + can_disconnect = remaining_social_accounts > 0 or has_password_auth + + if not can_disconnect: + if remaining_social_accounts == 0 and not has_password_auth: + return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first." + elif not has_password_auth: + return False, "Please set up email/password authentication before disconnecting this provider." + else: + return False, "Cannot disconnect this provider at this time." + + return True, "Provider can be safely disconnected." + + except Exception as e: + logger.error( + f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}") + return False, "Unable to verify disconnection safety. Please try again." + + @staticmethod + def get_connected_providers(user: "User") -> List[Dict]: + """ + Get all social providers connected to a user's account. + + Args: + user: The user to check + + Returns: + List of connected provider information + """ + try: + connected_providers = [] + + for social_account in user.socialaccount_set.all(): + can_disconnect, reason = SocialProviderService.can_disconnect_provider( + user, social_account.provider + ) + + provider_info = { + 'provider': social_account.provider, + 'provider_name': social_account.get_provider().name, + 'uid': social_account.uid, + 'date_joined': social_account.date_joined, + 'can_disconnect': can_disconnect, + 'disconnect_reason': reason if not can_disconnect else None, + 'extra_data': social_account.extra_data + } + + connected_providers.append(provider_info) + + return connected_providers + + except Exception as e: + logger.error(f"Error getting connected providers for user {user.id}: {e}") + return [] + + @staticmethod + def get_available_providers(request: HttpRequest) -> List[Dict]: + """ + Get all available social providers for the current site. + + Args: + request: The HTTP request + + Returns: + List of available provider information + """ + try: + site = get_current_site(request) + available_providers = [] + + # Get all social apps configured for this site + social_apps = SocialApp.objects.filter(sites=site).order_by('provider') + + for social_app in social_apps: + try: + provider = registry.by_id(social_app.provider) + + provider_info = { + 'id': social_app.provider, + 'name': provider.name, + 'auth_url': request.build_absolute_uri( + f'/accounts/{social_app.provider}/login/' + ), + 'connect_url': request.build_absolute_uri( + f'/api/v1/auth/social/connect/{social_app.provider}/' + ) + } + + available_providers.append(provider_info) + + except Exception as e: + logger.warning( + f"Error processing provider {social_app.provider}: {e}") + continue + + return available_providers + + except Exception as e: + logger.error(f"Error getting available providers: {e}") + return [] + + @staticmethod + def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]: + """ + Disconnect a social provider from a user's account. + + Args: + user: The user to disconnect from + provider: The provider to disconnect + + Returns: + Tuple of (success: bool, message: str) + """ + try: + # First check if disconnection is allowed + can_disconnect, reason = SocialProviderService.can_disconnect_provider( + user, provider) + + if not can_disconnect: + return False, reason + + # Find and delete the social account + social_accounts = user.socialaccount_set.filter(provider=provider) + + if not social_accounts.exists(): + return False, f"No {provider} account found to disconnect." + + # Delete all social accounts for this provider (in case of duplicates) + deleted_count = social_accounts.count() + social_accounts.delete() + + logger.info( + f"User {user.id} disconnected {deleted_count} {provider} account(s)") + + return True, f"{provider.title()} account disconnected successfully." + + except Exception as e: + logger.error(f"Error disconnecting {provider} for user {user.id}: {e}") + return False, f"Failed to disconnect {provider} account. Please try again." + + @staticmethod + def get_auth_status(user: "User") -> Dict: + """ + Get comprehensive authentication status for a user. + + Args: + user: The user to check + + Returns: + Dictionary with authentication status information + """ + try: + connected_providers = SocialProviderService.get_connected_providers(user) + + has_password_auth = ( + user.email and + user.has_usable_password() and + bool(user.password) + ) + + auth_methods_count = len(connected_providers) + \ + (1 if has_password_auth else 0) + + return { + 'user_id': user.id, + 'username': user.username, + 'email': user.email, + 'has_password_auth': has_password_auth, + 'connected_providers': connected_providers, + 'total_auth_methods': auth_methods_count, + 'can_disconnect_any': auth_methods_count > 1, + 'requires_password_setup': not has_password_auth and len(connected_providers) == 1 + } + + except Exception as e: + logger.error(f"Error getting auth status for user {user.id}: {e}") + return { + 'error': 'Unable to retrieve authentication status' + } + + @staticmethod + def validate_provider_exists(provider: str) -> Tuple[bool, str]: + """ + Validate that a social provider is configured and available. + + Args: + provider: The provider ID to validate + + Returns: + Tuple of (is_valid: bool, message: str) + """ + try: + # Check if provider is registered with allauth + if provider not in registry.provider_map: + return False, f"Provider '{provider}' is not supported." + + # Check if provider has a social app configured + if not SocialApp.objects.filter(provider=provider).exists(): + return False, f"Provider '{provider}' is not configured on this site." + + return True, f"Provider '{provider}' is valid and available." + + except Exception as e: + logger.error(f"Error validating provider {provider}: {e}") + return False, "Unable to validate provider." diff --git a/backend/apps/accounts/services/user_deletion_service.py b/backend/apps/accounts/services/user_deletion_service.py new file mode 100644 index 00000000..85108709 --- /dev/null +++ b/backend/apps/accounts/services/user_deletion_service.py @@ -0,0 +1,309 @@ +""" +User Deletion Service + +This service handles user account deletion while preserving submissions +and maintaining data integrity across the platform. +""" + +from django.utils import timezone +from django.db import transaction +from django.contrib.auth import get_user_model +from django.core.mail import send_mail +from django.conf import settings +from django.template.loader import render_to_string +from typing import Dict, Any, Tuple, Optional +import logging +import secrets +import string +from datetime import timedelta, datetime + +from apps.accounts.models import User + +logger = logging.getLogger(__name__) + +User = get_user_model() + + +class UserDeletionRequest: + """Model for tracking user deletion requests.""" + + def __init__(self, user: User, verification_code: str, expires_at: datetime): + self.user = user + self.verification_code = verification_code + self.expires_at = expires_at + self.created_at = timezone.now() + + +class UserDeletionService: + """Service for handling user account deletion with submission preservation.""" + + # In-memory storage for deletion requests (in production, use Redis or database) + _deletion_requests = {} + + @staticmethod + def can_delete_user(user: User) -> Tuple[bool, Optional[str]]: + """ + Check if a user can be safely deleted. + + Args: + user: User to check for deletion eligibility + + Returns: + Tuple[bool, Optional[str]]: (can_delete, reason_if_not) + """ + # Prevent deletion of superusers + if user.is_superuser: + return False, "Cannot delete superuser accounts" + + # Prevent deletion of staff/admin users + if user.is_staff: + return False, "Cannot delete staff accounts" + + # Check for system users (if you have any special system accounts) + if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']: + return False, "Cannot delete admin or moderator accounts" + + return True, None + + @staticmethod + def request_user_deletion(user: User) -> UserDeletionRequest: + """ + Create a deletion request for a user and send verification email. + + Args: + user: User requesting deletion + + Returns: + UserDeletionRequest: The deletion request object + + Raises: + ValueError: If user cannot be deleted + """ + # Check if user can be deleted + can_delete, reason = UserDeletionService.can_delete_user(user) + if not can_delete: + raise ValueError(reason) + + # Generate verification code + verification_code = ''.join(secrets.choice( + string.ascii_uppercase + string.digits) for _ in range(8)) + + # Set expiration (24 hours from now) + expires_at = timezone.now() + timezone.timedelta(hours=24) + + # Create deletion request + deletion_request = UserDeletionRequest(user, verification_code, expires_at) + + # Store request (in production, use Redis or database) + UserDeletionService._deletion_requests[verification_code] = deletion_request + + # Send verification email + UserDeletionService._send_deletion_verification_email( + user, verification_code, expires_at) + + return deletion_request + + @staticmethod + def verify_and_delete_user(verification_code: str) -> Dict[str, Any]: + """ + Verify deletion code and delete user account. + + Args: + verification_code: Verification code from email + + Returns: + Dict[str, Any]: Deletion result information + + Raises: + ValueError: If verification code is invalid or expired + """ + # Find deletion request + deletion_request = UserDeletionService._deletion_requests.get(verification_code) + if not deletion_request: + raise ValueError("Invalid verification code") + + # Check if expired + if timezone.now() > deletion_request.expires_at: + # Clean up expired request + del UserDeletionService._deletion_requests[verification_code] + raise ValueError("Verification code has expired") + + user = deletion_request.user + + # Perform deletion + result = UserDeletionService.delete_user_preserve_submissions(user) + + # Clean up deletion request + del UserDeletionService._deletion_requests[verification_code] + + # Add verification info to result + result['deletion_request'] = { + 'verification_code': verification_code, + 'created_at': deletion_request.created_at, + 'verified_at': timezone.now(), + } + + return result + + @staticmethod + def cancel_deletion_request(user: User) -> bool: + """ + Cancel a pending deletion request for a user. + + Args: + user: User whose deletion request to cancel + + Returns: + bool: True if request was found and cancelled, False if no request found + """ + # Find and remove any deletion requests for this user + to_remove = [] + for code, request in UserDeletionService._deletion_requests.items(): + if request.user.id == user.id: + to_remove.append(code) + + for code in to_remove: + del UserDeletionService._deletion_requests[code] + + return len(to_remove) > 0 + + @staticmethod + @transaction.atomic + def delete_user_preserve_submissions(user: User) -> Dict[str, Any]: + """ + Delete a user account while preserving all their submissions. + + Args: + user: User to delete + + Returns: + Dict[str, Any]: Information about the deletion and preserved submissions + """ + # Get or create the "deleted_user" placeholder + deleted_user_placeholder, created = User.objects.get_or_create( + username='deleted_user', + defaults={ + 'email': 'deleted@thrillwiki.com', + 'first_name': 'Deleted', + 'last_name': 'User', + 'is_active': False, + } + ) + + # Count submissions before transfer + submission_counts = UserDeletionService._count_user_submissions(user) + + # Transfer submissions to placeholder user + UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder) + + # Store user info before deletion + deleted_user_info = { + 'username': user.username, + 'user_id': getattr(user, 'user_id', user.id), + 'email': user.email, + 'date_joined': user.date_joined, + } + + # Delete the user account + user.delete() + + return { + 'deleted_user': deleted_user_info, + 'preserved_submissions': submission_counts, + 'transferred_to': { + 'username': deleted_user_placeholder.username, + 'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id), + } + } + + @staticmethod + def _count_user_submissions(user: User) -> Dict[str, int]: + """Count all submissions for a user.""" + counts = {} + + # Count different types of submissions + # Note: These are placeholder counts - adjust based on your actual models + counts['park_reviews'] = getattr( + user, 'park_reviews', user.__class__.objects.none()).count() + counts['ride_reviews'] = getattr( + user, 'ride_reviews', user.__class__.objects.none()).count() + counts['uploaded_park_photos'] = getattr( + user, 'uploaded_park_photos', user.__class__.objects.none()).count() + counts['uploaded_ride_photos'] = getattr( + user, 'uploaded_ride_photos', user.__class__.objects.none()).count() + counts['top_lists'] = getattr( + user, 'top_lists', user.__class__.objects.none()).count() + counts['edit_submissions'] = getattr( + user, 'edit_submissions', user.__class__.objects.none()).count() + counts['photo_submissions'] = getattr( + user, 'photo_submissions', user.__class__.objects.none()).count() + + return counts + + @staticmethod + def _transfer_user_submissions(user: User, placeholder_user: User) -> None: + """Transfer all user submissions to placeholder user.""" + + # Transfer different types of submissions + # Note: Adjust these based on your actual model relationships + + # Park reviews + if hasattr(user, 'park_reviews'): + user.park_reviews.all().update(user=placeholder_user) + + # Ride reviews + if hasattr(user, 'ride_reviews'): + user.ride_reviews.all().update(user=placeholder_user) + + # Uploaded photos + if hasattr(user, 'uploaded_park_photos'): + user.uploaded_park_photos.all().update(user=placeholder_user) + + if hasattr(user, 'uploaded_ride_photos'): + user.uploaded_ride_photos.all().update(user=placeholder_user) + + # Top lists + if hasattr(user, 'top_lists'): + user.top_lists.all().update(user=placeholder_user) + + # Edit submissions + if hasattr(user, 'edit_submissions'): + user.edit_submissions.all().update(user=placeholder_user) + + # Photo submissions + if hasattr(user, 'photo_submissions'): + user.photo_submissions.all().update(user=placeholder_user) + + @staticmethod + def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None: + """Send verification email for account deletion.""" + try: + context = { + 'user': user, + 'verification_code': verification_code, + 'expires_at': expires_at, + 'site_name': 'ThrillWiki', + 'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'), + } + + subject = 'ThrillWiki: Confirm Account Deletion' + html_message = render_to_string( + 'emails/account_deletion_verification.html', context) + plain_message = render_to_string( + 'emails/account_deletion_verification.txt', context) + + send_mail( + subject=subject, + message=plain_message, + html_message=html_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + fail_silently=False, + ) + + logger.info(f"Deletion verification email sent to {user.email}") + + except Exception as e: + logger.error( + f"Failed to send deletion verification email to {user.email}: {str(e)}") + raise diff --git a/backend/apps/api/v1/accounts/views.py b/backend/apps/api/v1/accounts/views.py index 6478a04a..b44a8760 100644 --- a/backend/apps/api/v1/accounts/views.py +++ b/backend/apps/api/v1/accounts/views.py @@ -6,23 +6,6 @@ user deletion while preserving submissions, profile management, settings, preferences, privacy, notifications, and security. """ -from rest_framework import status -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated, IsAdminUser -from rest_framework.response import Response -from drf_spectacular.utils import extend_schema, OpenApiParameter -from drf_spectacular.types import OpenApiTypes -from django.shortcuts import get_object_or_404 -from rest_framework.permissions import AllowAny -from django.utils import timezone -from apps.accounts.models import ( - User, - UserProfile, - TopList, - UserNotification, - NotificationPreference, -) -from apps.accounts.services import UserDeletionService from apps.api.v1.serializers.accounts import ( CompleteUserSerializer, UserPreferencesSerializer, @@ -39,6 +22,27 @@ from apps.api.v1.serializers.accounts import ( MarkNotificationsReadSerializer, AvatarUploadSerializer, ) +from apps.accounts.services import UserDeletionService +from apps.accounts.models import ( + User, + UserProfile, + TopList, + UserNotification, + NotificationPreference, +) +import logging +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated, IsAdminUser +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import AllowAny +from django.utils import timezone + +# Set up logging +logger = logging.getLogger(__name__) @extend_schema( @@ -106,7 +110,7 @@ def delete_user_preserve_submissions(request, user_id): Delete a user while preserving all their submissions. This endpoint allows administrators to delete user accounts while - preserving all user-generated content (reviews, photos, top lists, etc.). + preserving all user - generated content(reviews, photos, top lists, etc.). All submissions are transferred to a system "deleted_user" placeholder. **Admin Only**: This endpoint requires admin permissions. @@ -119,14 +123,71 @@ def delete_user_preserve_submissions(request, user_id): # Check if user can be deleted can_delete, reason = UserDeletionService.can_delete_user(user) if not can_delete: + # Log the attempt for security monitoring + logger.warning( + f"Admin user {request.user.username} attempted to delete protected user {user.username} (ID: {user_id}). Reason: {reason}", + extra={ + "admin_user": request.user.username, + "target_user": user.username, + "target_user_id": user_id, + "is_superuser": user.is_superuser, + "user_role": user.role, + "rejection_reason": reason, + } + ) + + # Determine error code based on reason + error_code = "DELETION_FORBIDDEN" + if "superuser" in reason.lower(): + error_code = "SUPERUSER_DELETION_FORBIDDEN" + elif "admin" in reason.lower(): + error_code = "ADMIN_DELETION_FORBIDDEN" + elif "system" in reason.lower(): + error_code = "SYSTEM_USER_DELETION_FORBIDDEN" + return Response( - {"success": False, "error": f"Cannot delete user: {reason}"}, + { + "success": False, + "error": f"Cannot delete user: {reason}", + "error_code": error_code, + "user_info": { + "username": user.username, + "user_id": user.user_id, + "role": user.role, + "is_superuser": user.is_superuser, + "is_staff": user.is_staff, + }, + "help_text": "Contact system administrator if you need to delete this account type.", + }, status=status.HTTP_400_BAD_REQUEST, ) + # Log the successful deletion attempt + logger.info( + f"Admin user {request.user.username} is deleting user {user.username} (ID: {user_id})", + extra={ + "admin_user": request.user.username, + "target_user": user.username, + "target_user_id": user_id, + "action": "user_deletion", + } + ) + # Perform the deletion result = UserDeletionService.delete_user_preserve_submissions(user) + # Log successful deletion + logger.info( + f"Successfully deleted user {result['deleted_user']['username']} (ID: {user_id}) by admin {request.user.username}", + extra={ + "admin_user": request.user.username, + "deleted_user": result['deleted_user']['username'], + "deleted_user_id": user_id, + "preserved_submissions": result['preserved_submissions'], + "action": "user_deletion_completed", + } + ) + return Response( { "success": True, @@ -137,8 +198,25 @@ def delete_user_preserve_submissions(request, user_id): ) except Exception as e: + # Log the error for debugging + logger.error( + f"Error deleting user {user_id} by admin {request.user.username}: {str(e)}", + extra={ + "admin_user": request.user.username, + "target_user_id": user_id, + "error": str(e), + "action": "user_deletion_error", + }, + exc_info=True + ) + return Response( - {"success": False, "error": f"Error deleting user: {str(e)}"}, + { + "success": False, + "error": f"Error deleting user: {str(e)}", + "error_code": "DELETION_ERROR", + "help_text": "Please try again or contact system administrator if the problem persists.", + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -185,7 +263,7 @@ def request_account_deletion(request): account. A verification code will be sent to their email address, and the account will only be deleted after they provide the correct code. - **Authentication Required**: User must be logged in. + **Authentication Required**: User must be logged in . **Email Verification**: A verification code is sent to the user's email. @@ -197,6 +275,17 @@ def request_account_deletion(request): # Create deletion request and send email deletion_request = UserDeletionService.request_user_deletion(user) + # Log the self-service deletion request + logger.info( + f"User {user.username} (ID: {user.user_id}) requested account deletion", + extra={ + "user": user.username, + "user_id": user.user_id, + "email": user.email, + "action": "self_deletion_request", + } + ) + return Response( { "success": True, @@ -208,12 +297,65 @@ def request_account_deletion(request): ) except ValueError as e: + # Log the rejection for security monitoring + logger.warning( + f"User {request.user.username} (ID: {request.user.user_id}) attempted self-deletion but was rejected: {str(e)}", + extra={ + "user": request.user.username, + "user_id": request.user.user_id, + "is_superuser": request.user.is_superuser, + "user_role": request.user.role, + "rejection_reason": str(e), + "action": "self_deletion_rejected", + } + ) + + # Determine error code based on reason + error_message = str(e) + error_code = "DELETION_FORBIDDEN" + if "superuser" in error_message.lower(): + error_code = "SUPERUSER_DELETION_FORBIDDEN" + elif "admin" in error_message.lower(): + error_code = "ADMIN_DELETION_FORBIDDEN" + elif "system" in error_message.lower(): + error_code = "SYSTEM_USER_DELETION_FORBIDDEN" + return Response( - {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST + { + "success": False, + "error": error_message, + "error_code": error_code, + "user_info": { + "username": request.user.username, + "user_id": request.user.user_id, + "role": request.user.role, + "is_superuser": request.user.is_superuser, + "is_staff": request.user.is_staff, + }, + "help_text": "Superuser and admin accounts cannot be self-deleted for security reasons. Contact system administrator if you need to delete this account.", + }, + status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: + # Log the error for debugging + logger.error( + f"Error creating deletion request for user {request.user.username} (ID: {request.user.user_id}): {str(e)}", + extra={ + "user": request.user.username, + "user_id": request.user.user_id, + "error": str(e), + "action": "self_deletion_error", + }, + exc_info=True + ) + return Response( - {"success": False, "error": f"Error creating deletion request: {str(e)}"}, + { + "success": False, + "error": f"Error creating deletion request: {str(e)}", + "error_code": "DELETION_REQUEST_ERROR", + "help_text": "Please try again or contact support if the problem persists.", + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -1279,7 +1421,7 @@ def get_user_notifications(request): unread_count = UserNotification.objects.filter(user=user, is_read=False).count() # Apply pagination - notifications = queryset[offset : offset + limit] + notifications = queryset[offset: offset + limit] # Build pagination URLs request_url = request.build_absolute_uri().split("?")[0] @@ -1517,11 +1659,13 @@ def upload_avatar(request): ) except Exception as e: + print(f"Upload avatar - Error saving to profile: {e}") return Response( {"success": False, "error": f"Failed to upload avatar: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, ) + print(f"Upload avatar - Serializer errors: {serializer.errors}") return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/apps/api/v1/auth/models.py b/backend/apps/api/v1/auth/models.py index 981d4caf..ee53343b 100644 --- a/backend/apps/api/v1/auth/models.py +++ b/backend/apps/api/v1/auth/models.py @@ -1,33 +1,3 @@ -from django.db import models -from django.conf import settings -from django.utils import timezone - - -class PasswordReset(models.Model): - """Persisted password reset tokens for API-driven password resets.""" - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="password_resets", - ) - token = models.CharField(max_length=128, unique=True, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) - expires_at = models.DateTimeField() - used = models.BooleanField(default=False) - - class Meta: - ordering = ["-created_at"] - verbose_name = "Password Reset" - verbose_name_plural = "Password Resets" - - def is_expired(self) -> bool: - return timezone.now() > self.expires_at - - def mark_used(self) -> None: - self.used = True - self.save(update_fields=["used"]) - - def __str__(self): - user_id = getattr(self, "user_id", None) - return f"PasswordReset(user={user_id}, token={self.token[:8]}..., used={self.used})" +# This file is intentionally empty. +# All models are now in their appropriate apps to avoid conflicts. +# PasswordReset model is available in apps.accounts.models diff --git a/backend/apps/api/v1/auth/serializers.py b/backend/apps/api/v1/auth/serializers.py index 5a11ac74..fd6a63b9 100644 --- a/backend/apps/api/v1/auth/serializers.py +++ b/backend/apps/api/v1/auth/serializers.py @@ -18,7 +18,7 @@ from django.utils.crypto import get_random_string from django.contrib.auth import get_user_model from django.utils import timezone from datetime import timedelta -from .models import PasswordReset +from apps.accounts.models import PasswordReset UserModel = get_user_model() @@ -62,8 +62,7 @@ class ModelChoices: "id": 1, "username": "john_doe", "email": "john@example.com", - "first_name": "John", - "last_name": "Doe", + "display_name": "John Doe", "date_joined": "2024-01-01T12:00:00Z", "is_active": True, "avatar_url": "https://example.com/avatars/john.jpg", @@ -83,12 +82,10 @@ class UserOutputSerializer(serializers.ModelSerializer): "id", "username", "email", - "first_name", - "last_name", + "display_name", "date_joined", "is_active", "avatar_url", - "display_name", ] read_only_fields = ["id", "date_joined", "is_active"] @@ -127,7 +124,8 @@ class LoginInputSerializer(serializers.Serializer): class LoginOutputSerializer(serializers.Serializer): """Output serializer for successful login.""" - token = serializers.CharField() + access = serializers.CharField() + refresh = serializers.CharField() user = UserOutputSerializer() message = serializers.CharField() @@ -149,14 +147,14 @@ class SignupInputSerializer(serializers.ModelSerializer): fields = [ "username", "email", - "first_name", - "last_name", + "display_name", "password", "password_confirm", ] extra_kwargs = { "password": {"write_only": True}, "email": {"required": True}, + "display_name": {"required": True}, } def validate_email(self, value): @@ -202,7 +200,8 @@ class SignupInputSerializer(serializers.ModelSerializer): class SignupOutputSerializer(serializers.Serializer): """Output serializer for successful signup.""" - token = serializers.CharField() + access = serializers.CharField() + refresh = serializers.CharField() user = UserOutputSerializer() message = serializers.CharField() diff --git a/backend/apps/api/v1/auth/serializers/__init__.py b/backend/apps/api/v1/auth/serializers/__init__.py new file mode 100644 index 00000000..0f1cfab1 --- /dev/null +++ b/backend/apps/api/v1/auth/serializers/__init__.py @@ -0,0 +1,30 @@ +""" +Auth Serializers Package + +This package contains all authentication-related serializers including +login, signup, logout, password management, and social authentication. +""" + +from .social import ( + ConnectedProviderSerializer, + AvailableProviderSerializer, + SocialAuthStatusSerializer, + ConnectProviderInputSerializer, + ConnectProviderOutputSerializer, + DisconnectProviderOutputSerializer, + SocialProviderListOutputSerializer, + ConnectedProvidersListOutputSerializer, + SocialProviderErrorSerializer, +) + +__all__ = [ + 'ConnectedProviderSerializer', + 'AvailableProviderSerializer', + 'SocialAuthStatusSerializer', + 'ConnectProviderInputSerializer', + 'ConnectProviderOutputSerializer', + 'DisconnectProviderOutputSerializer', + 'SocialProviderListOutputSerializer', + 'ConnectedProvidersListOutputSerializer', + 'SocialProviderErrorSerializer', +] diff --git a/backend/apps/api/v1/auth/serializers/social.py b/backend/apps/api/v1/auth/serializers/social.py new file mode 100644 index 00000000..18202e99 --- /dev/null +++ b/backend/apps/api/v1/auth/serializers/social.py @@ -0,0 +1,201 @@ +""" +Social Provider Management Serializers + +Serializers for handling social provider connection/disconnection requests +and responses in the ThrillWiki API. +""" + +from rest_framework import serializers +from django.contrib.auth import get_user_model +from typing import Dict, List + +User = get_user_model() + + +class ConnectedProviderSerializer(serializers.Serializer): + """Serializer for connected social provider information.""" + + provider = serializers.CharField( + help_text="Provider ID (e.g., 'google', 'discord')" + ) + provider_name = serializers.CharField( + help_text="Human-readable provider name" + ) + uid = serializers.CharField( + help_text="User ID on the social provider" + ) + date_joined = serializers.DateTimeField( + help_text="When this provider was connected" + ) + can_disconnect = serializers.BooleanField( + help_text="Whether this provider can be safely disconnected" + ) + disconnect_reason = serializers.CharField( + allow_null=True, + required=False, + help_text="Reason why provider cannot be disconnected (if applicable)" + ) + extra_data = serializers.JSONField( + required=False, + help_text="Additional data from the social provider" + ) + + +class AvailableProviderSerializer(serializers.Serializer): + """Serializer for available social provider information.""" + + id = serializers.CharField( + help_text="Provider ID (e.g., 'google', 'discord')" + ) + name = serializers.CharField( + help_text="Human-readable provider name" + ) + auth_url = serializers.URLField( + help_text="URL to initiate authentication with this provider" + ) + connect_url = serializers.URLField( + help_text="API URL to connect this provider" + ) + + +class SocialAuthStatusSerializer(serializers.Serializer): + """Serializer for comprehensive social authentication status.""" + + user_id = serializers.IntegerField( + help_text="User's ID" + ) + username = serializers.CharField( + help_text="User's username" + ) + email = serializers.EmailField( + help_text="User's email address" + ) + has_password_auth = serializers.BooleanField( + help_text="Whether user has email/password authentication set up" + ) + connected_providers = ConnectedProviderSerializer( + many=True, + help_text="List of connected social providers" + ) + total_auth_methods = serializers.IntegerField( + help_text="Total number of authentication methods available" + ) + can_disconnect_any = serializers.BooleanField( + help_text="Whether user can safely disconnect any provider" + ) + requires_password_setup = serializers.BooleanField( + help_text="Whether user needs to set up password before disconnecting" + ) + + +class ConnectProviderInputSerializer(serializers.Serializer): + """Serializer for social provider connection requests.""" + + provider = serializers.CharField( + help_text="Provider ID to connect (e.g., 'google', 'discord')" + ) + + def validate_provider(self, value): + """Validate that the provider is supported and configured.""" + from apps.accounts.services.social_provider_service import SocialProviderService + + is_valid, message = SocialProviderService.validate_provider_exists(value) + if not is_valid: + raise serializers.ValidationError(message) + + return value + + +class ConnectProviderOutputSerializer(serializers.Serializer): + """Serializer for social provider connection responses.""" + + success = serializers.BooleanField( + help_text="Whether the connection was successful" + ) + message = serializers.CharField( + help_text="Success or error message" + ) + provider = serializers.CharField( + help_text="Provider that was connected" + ) + auth_url = serializers.URLField( + required=False, + help_text="URL to complete the connection process" + ) + + +class DisconnectProviderOutputSerializer(serializers.Serializer): + """Serializer for social provider disconnection responses.""" + + success = serializers.BooleanField( + help_text="Whether the disconnection was successful" + ) + message = serializers.CharField( + help_text="Success or error message" + ) + provider = serializers.CharField( + help_text="Provider that was disconnected" + ) + remaining_providers = serializers.ListField( + child=serializers.CharField(), + help_text="List of remaining connected providers" + ) + has_password_auth = serializers.BooleanField( + help_text="Whether user still has password authentication" + ) + suggestions = serializers.ListField( + child=serializers.CharField(), + required=False, + help_text="Suggestions for maintaining account access (if applicable)" + ) + + +class SocialProviderListOutputSerializer(serializers.Serializer): + """Serializer for listing available social providers.""" + + available_providers = AvailableProviderSerializer( + many=True, + help_text="List of available social providers" + ) + count = serializers.IntegerField( + help_text="Number of available providers" + ) + + +class ConnectedProvidersListOutputSerializer(serializers.Serializer): + """Serializer for listing connected social providers.""" + + connected_providers = ConnectedProviderSerializer( + many=True, + help_text="List of connected social providers" + ) + count = serializers.IntegerField( + help_text="Number of connected providers" + ) + has_password_auth = serializers.BooleanField( + help_text="Whether user has password authentication" + ) + can_disconnect_any = serializers.BooleanField( + help_text="Whether user can safely disconnect any provider" + ) + + +class SocialProviderErrorSerializer(serializers.Serializer): + """Serializer for social provider error responses.""" + + error = serializers.CharField( + help_text="Error message" + ) + code = serializers.CharField( + required=False, + help_text="Error code for programmatic handling" + ) + suggestions = serializers.ListField( + child=serializers.CharField(), + required=False, + help_text="Suggestions for resolving the error" + ) + provider = serializers.CharField( + required=False, + help_text="Provider related to the error (if applicable)" + ) diff --git a/backend/apps/api/v1/auth/urls.py b/backend/apps/api/v1/auth/urls.py index 545e023e..adcc33c7 100644 --- a/backend/apps/api/v1/auth/urls.py +++ b/backend/apps/api/v1/auth/urls.py @@ -5,31 +5,84 @@ This module contains URL patterns for core authentication functionality only. User profiles and top lists are handled by the dedicated accounts app. """ -from django.urls import path -from . import views +from django.urls import path, include +from .views import ( + # Main auth views + LoginAPIView, + SignupAPIView, + LogoutAPIView, + CurrentUserAPIView, + PasswordResetAPIView, + PasswordChangeAPIView, + SocialProvidersAPIView, + AuthStatusAPIView, + # Social provider management views + AvailableProvidersAPIView, + ConnectedProvidersAPIView, + ConnectProviderAPIView, + DisconnectProviderAPIView, + SocialAuthStatusAPIView, +) +from rest_framework_simplejwt.views import TokenRefreshView + urlpatterns = [ # Core authentication endpoints - path("login/", views.LoginAPIView.as_view(), name="auth-login"), - path("signup/", views.SignupAPIView.as_view(), name="auth-signup"), - path("logout/", views.LogoutAPIView.as_view(), name="auth-logout"), - path("user/", views.CurrentUserAPIView.as_view(), name="auth-current-user"), + path("login/", LoginAPIView.as_view(), name="auth-login"), + path("signup/", SignupAPIView.as_view(), name="auth-signup"), + path("logout/", LogoutAPIView.as_view(), name="auth-logout"), + path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"), + + # JWT token management + path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"), + + # Social authentication endpoints (dj-rest-auth) + path("social/", include("dj_rest_auth.registration.urls")), + path( "password/reset/", - views.PasswordResetAPIView.as_view(), + PasswordResetAPIView.as_view(), name="auth-password-reset", ), path( "password/change/", - views.PasswordChangeAPIView.as_view(), + PasswordChangeAPIView.as_view(), name="auth-password-change", ), path( "social/providers/", - views.SocialProvidersAPIView.as_view(), + SocialProvidersAPIView.as_view(), name="auth-social-providers", ), - path("status/", views.AuthStatusAPIView.as_view(), name="auth-status"), + + # Social provider management endpoints + path( + "social/providers/available/", + AvailableProvidersAPIView.as_view(), + name="auth-social-providers-available", + ), + path( + "social/connected/", + ConnectedProvidersAPIView.as_view(), + name="auth-social-connected", + ), + path( + "social/connect//", + ConnectProviderAPIView.as_view(), + name="auth-social-connect", + ), + path( + "social/disconnect//", + DisconnectProviderAPIView.as_view(), + name="auth-social-disconnect", + ), + path( + "social/status/", + SocialAuthStatusAPIView.as_view(), + name="auth-social-status", + ), + + path("status/", AuthStatusAPIView.as_view(), name="auth-status"), ] # Note: User profiles and top lists functionality is now handled by the accounts app diff --git a/backend/apps/api/v1/auth/views.py b/backend/apps/api/v1/auth/views.py index 134dddab..48e0b0c4 100644 --- a/backend/apps/api/v1/auth/views.py +++ b/backend/apps/api/v1/auth/views.py @@ -6,6 +6,16 @@ login, signup, logout, password management, social authentication, user profiles, and top lists. """ +from .serializers.social import ( + ConnectedProviderSerializer, + AvailableProviderSerializer, + SocialAuthStatusSerializer, + ConnectProviderInputSerializer, + ConnectProviderOutputSerializer, + DisconnectProviderOutputSerializer, + SocialProviderErrorSerializer, +) +from apps.accounts.services.social_provider_service import SocialProviderService from django.contrib.auth import authenticate, login, logout, get_user_model from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError @@ -19,7 +29,8 @@ from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated from drf_spectacular.utils import extend_schema, extend_schema_view -from .serializers import ( +# Import from the main serializers.py file (not the serializers package) +from ..serializers import ( # Authentication serializers LoginInputSerializer, LoginOutputSerializer, @@ -168,13 +179,17 @@ class LoginAPIView(APIView): if getattr(user, "is_active", False): # pass a real HttpRequest to Django login login(_get_underlying_request(request), user) - from rest_framework.authtoken.models import Token - token, _ = Token.objects.get_or_create(user=user) + # Generate JWT tokens + from rest_framework_simplejwt.tokens import RefreshToken + + refresh = RefreshToken.for_user(user) + access_token = refresh.access_token response_serializer = LoginOutputSerializer( { - "token": token.key, + "access": str(access_token), + "refresh": str(refresh), "user": user, "message": "Login successful", } @@ -228,13 +243,17 @@ class SignupAPIView(APIView): user = serializer.save() # pass a real HttpRequest to Django login login(_get_underlying_request(request), user) # type: ignore[arg-type] - from rest_framework.authtoken.models import Token - token, _ = Token.objects.get_or_create(user=user) + # Generate JWT tokens + from rest_framework_simplejwt.tokens import RefreshToken + + refresh = RefreshToken.for_user(user) + access_token = refresh.access_token response_serializer = SignupOutputSerializer( { - "token": token.key, + "access": str(access_token), + "refresh": str(refresh), "user": user, "message": "Registration successful", } @@ -247,7 +266,7 @@ class SignupAPIView(APIView): @extend_schema_view( post=extend_schema( summary="User logout", - description="Logout the current user and invalidate their token.", + description="Logout the current user and blacklist their refresh token.", responses={ 200: LogoutOutputSerializer, 401: "Unauthorized", @@ -263,7 +282,26 @@ class LogoutAPIView(APIView): def post(self, request: Request) -> Response: try: - # Delete the token for token-based auth + # Get refresh token from request data with proper type handling + refresh_token = None + if hasattr(request, 'data') and request.data is not None: + data = getattr(request, 'data', {}) + if hasattr(data, 'get'): + refresh_token = data.get("refresh") + + if refresh_token and isinstance(refresh_token, str): + # Blacklist the refresh token + from rest_framework_simplejwt.tokens import RefreshToken + try: + # Create RefreshToken from string and blacklist it + refresh_token_obj = RefreshToken( + refresh_token) # type: ignore[arg-type] + refresh_token_obj.blacklist() + except Exception: + # Token might be invalid or already blacklisted + pass + + # Also delete the old token for backward compatibility if hasattr(request.user, "auth_token"): request.user.auth_token.delete() @@ -464,6 +502,236 @@ class AuthStatusAPIView(APIView): return Response(serializer.data) +# === SOCIAL PROVIDER MANAGEMENT API VIEWS === + + +@extend_schema_view( + get=extend_schema( + summary="Get available social providers", + description="Retrieve list of available social authentication providers.", + responses={ + 200: AvailableProviderSerializer(many=True), + }, + tags=["Social Authentication"], + ), +) +class AvailableProvidersAPIView(APIView): + """API endpoint to get available social providers.""" + + permission_classes = [AllowAny] + serializer_class = AvailableProviderSerializer + + def get(self, request: Request) -> Response: + providers = [ + { + "provider": "google", + "name": "Google", + "login_url": "/auth/social/google/", + "connect_url": "/auth/social/connect/google/", + }, + { + "provider": "discord", + "name": "Discord", + "login_url": "/auth/social/discord/", + "connect_url": "/auth/social/connect/discord/", + } + ] + + serializer = AvailableProviderSerializer(providers, many=True) + return Response(serializer.data) + + +@extend_schema_view( + get=extend_schema( + summary="Get connected social providers", + description="Retrieve list of social providers connected to the user's account.", + responses={ + 200: ConnectedProviderSerializer(many=True), + 401: "Unauthorized", + }, + tags=["Social Authentication"], + ), +) +class ConnectedProvidersAPIView(APIView): + """API endpoint to get user's connected social providers.""" + + permission_classes = [IsAuthenticated] + serializer_class = ConnectedProviderSerializer + + def get(self, request: Request) -> Response: + service = SocialProviderService() + providers = service.get_connected_providers(request.user) + + serializer = ConnectedProviderSerializer(providers, many=True) + return Response(serializer.data) + + +@extend_schema_view( + post=extend_schema( + summary="Connect social provider", + description="Connect a social authentication provider to the user's account.", + request=ConnectProviderInputSerializer, + responses={ + 200: ConnectProviderOutputSerializer, + 400: SocialProviderErrorSerializer, + 401: "Unauthorized", + }, + tags=["Social Authentication"], + ), +) +class ConnectProviderAPIView(APIView): + """API endpoint to connect a social provider.""" + + permission_classes = [IsAuthenticated] + serializer_class = ConnectProviderInputSerializer + + def post(self, request: Request, provider: str) -> Response: + # Validate provider + if provider not in ['google', 'discord']: + return Response( + { + "success": False, + "error": "INVALID_PROVIDER", + "message": f"Provider '{provider}' is not supported", + "suggestions": ["Use 'google' or 'discord'"] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = ConnectProviderInputSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + { + "success": False, + "error": "VALIDATION_ERROR", + "message": "Invalid request data", + "details": serializer.errors, + "suggestions": ["Provide a valid access_token"] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + access_token = serializer.validated_data['access_token'] + + try: + service = SocialProviderService() + result = service.connect_provider(request.user, provider, access_token) + + response_serializer = ConnectProviderOutputSerializer(result) + return Response(response_serializer.data) + + except Exception as e: + return Response( + { + "success": False, + "error": "CONNECTION_FAILED", + "message": str(e), + "suggestions": [ + "Verify the access token is valid", + "Ensure the provider account is not already connected to another user" + ] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + +@extend_schema_view( + post=extend_schema( + summary="Disconnect social provider", + description="Disconnect a social authentication provider from the user's account.", + responses={ + 200: DisconnectProviderOutputSerializer, + 400: SocialProviderErrorSerializer, + 401: "Unauthorized", + }, + tags=["Social Authentication"], + ), +) +class DisconnectProviderAPIView(APIView): + """API endpoint to disconnect a social provider.""" + + permission_classes = [IsAuthenticated] + serializer_class = DisconnectProviderOutputSerializer + + def post(self, request: Request, provider: str) -> Response: + # Validate provider + if provider not in ['google', 'discord']: + return Response( + { + "success": False, + "error": "INVALID_PROVIDER", + "message": f"Provider '{provider}' is not supported", + "suggestions": ["Use 'google' or 'discord'"] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + service = SocialProviderService() + + # Check if disconnection is safe + can_disconnect, reason = service.can_disconnect_provider( + request.user, provider) + if not can_disconnect: + return Response( + { + "success": False, + "error": "UNSAFE_DISCONNECTION", + "message": reason, + "suggestions": [ + "Set up email/password authentication before disconnecting", + "Connect another social provider before disconnecting this one" + ] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Perform disconnection + result = service.disconnect_provider(request.user, provider) + + response_serializer = DisconnectProviderOutputSerializer(result) + return Response(response_serializer.data) + + except Exception as e: + return Response( + { + "success": False, + "error": "DISCONNECTION_FAILED", + "message": str(e), + "suggestions": [ + "Verify the provider is currently connected", + "Ensure you have alternative authentication methods" + ] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + +@extend_schema_view( + get=extend_schema( + summary="Get social authentication status", + description="Get comprehensive social authentication status for the user.", + responses={ + 200: SocialAuthStatusSerializer, + 401: "Unauthorized", + }, + tags=["Social Authentication"], + ), +) +class SocialAuthStatusAPIView(APIView): + """API endpoint to get social authentication status.""" + + permission_classes = [IsAuthenticated] + serializer_class = SocialAuthStatusSerializer + + def get(self, request: Request) -> Response: + service = SocialProviderService() + auth_status = service.get_auth_status(request.user) + + serializer = SocialAuthStatusSerializer(auth_status) + return Response(serializer.data) + + # Note: User Profile, Top List, and Top List Item ViewSets are now handled # by the dedicated accounts app at backend/apps/api/v1/accounts/views.py # to avoid duplication and maintain clean separation of concerns. diff --git a/backend/apps/api/v1/serializers/accounts.py b/backend/apps/api/v1/serializers/accounts.py index abbb8314..1ad5bc78 100644 --- a/backend/apps/api/v1/serializers/accounts.py +++ b/backend/apps/api/v1/serializers/accounts.py @@ -857,17 +857,54 @@ class MarkNotificationsReadSerializer(serializers.Serializer): ) ] ) -class AvatarUploadSerializer(serializers.ModelSerializer): +class AvatarUploadSerializer(serializers.Serializer): """Serializer for uploading user avatar.""" - class Meta: - model = UserProfile - fields = ["avatar"] + # Use FileField instead of ImageField to bypass Django's image validation + avatar = serializers.FileField() def validate_avatar(self, value): """Validate avatar file.""" - if value: - # Add any avatar-specific validation here - # The CloudflareImagesField will handle the upload + if not value: + raise serializers.ValidationError("No file provided") + + # Check file size constraints (max 10MB for Cloudflare Images) + if hasattr(value, 'size') and value.size > 10 * 1024 * 1024: + raise serializers.ValidationError( + "Image file too large. Maximum size is 10MB.") + + # Try to validate with PIL + try: + from PIL import Image + import io + + value.seek(0) + image_data = value.read() + value.seek(0) # Reset for later use + + if len(image_data) == 0: + raise serializers.ValidationError("File appears to be empty") + + # Try to open with PIL + image = Image.open(io.BytesIO(image_data)) + + # Verify it's a valid image + image.verify() + + # Check image dimensions (max 12,000x12,000 for Cloudflare Images) + if image.size[0] > 12000 or image.size[1] > 12000: + raise serializers.ValidationError( + "Image dimensions too large. Maximum is 12,000x12,000 pixels.") + + # Check if it's a supported format + if image.format not in ['JPEG', 'PNG', 'GIF', 'WEBP']: + raise serializers.ValidationError( + f"Unsupported image format: {image.format}. Supported formats: JPEG, PNG, GIF, WebP.") + + except serializers.ValidationError: + raise # Re-raise validation errors + except Exception as e: + # PIL validation failed, but let Cloudflare Images try to process it pass + return value diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index 36f5f7e4..bddb43a1 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -6,15 +6,8 @@ and DRF Router patterns for automatic URL generation. """ from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView +# Import other views from the views directory from .views import ( - LoginAPIView, - SignupAPIView, - LogoutAPIView, - CurrentUserAPIView, - PasswordResetAPIView, - PasswordChangeAPIView, - SocialProvidersAPIView, - AuthStatusAPIView, HealthCheckAPIView, PerformanceMetricsAPIView, SimpleHealthAPIView, @@ -40,16 +33,7 @@ urlpatterns = [ # API Documentation endpoints are handled by main Django URLs # See backend/thrillwiki/urls.py for documentation endpoints # Authentication endpoints - path("auth/login/", LoginAPIView.as_view(), name="login"), - path("auth/signup/", SignupAPIView.as_view(), name="signup"), - path("auth/logout/", LogoutAPIView.as_view(), name="logout"), - path("auth/user/", CurrentUserAPIView.as_view(), name="current-user"), - path("auth/password/reset/", PasswordResetAPIView.as_view(), name="password-reset"), - path( - "auth/password/change/", PasswordChangeAPIView.as_view(), name="password-change" - ), - path("auth/providers/", SocialProvidersAPIView.as_view(), name="social-providers"), - path("auth/status/", AuthStatusAPIView.as_view(), name="auth-status"), + path("auth/", include("apps.api.v1.auth.urls")), # Health check endpoints path("health/", HealthCheckAPIView.as_view(), name="health-check"), path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"), diff --git a/backend/apps/core/__init__.py b/backend/apps/core/__init__.py index e69de29b..d8ed62a3 100644 --- a/backend/apps/core/__init__.py +++ b/backend/apps/core/__init__.py @@ -0,0 +1 @@ +default_app_config = "apps.core.apps.CoreConfig" diff --git a/backend/apps/parks/management/commands/seed_sample_data.py b/backend/apps/parks/management/commands/seed_sample_data.py index 66aa94c7..07c1ba72 100644 --- a/backend/apps/parks/management/commands/seed_sample_data.py +++ b/backend/apps/parks/management/commands/seed_sample_data.py @@ -1030,8 +1030,7 @@ class Command(BaseCommand): username="testuser", defaults={ "email": "test@example.com", - "first_name": "Test", - "last_name": "User", + "display_name": "Test User", }, ) if created: diff --git a/backend/config/django/base.py b/backend/config/django/base.py index 5a9042e0..a86333ae 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -3,6 +3,7 @@ Base Django settings for thrillwiki project. Common settings shared across all environments. """ +from datetime import timedelta import sys from pathlib import Path from decouple import config @@ -64,7 +65,12 @@ DJANGO_APPS = [ THIRD_PARTY_APPS = [ "rest_framework", # Django REST Framework - "rest_framework.authtoken", # Token authentication + # Token authentication (kept for backward compatibility) + "rest_framework.authtoken", + "rest_framework_simplejwt", # JWT authentication + "rest_framework_simplejwt.token_blacklist", # JWT token blacklist + "dj_rest_auth", # REST authentication with JWT support + "dj_rest_auth.registration", # REST registration support "drf_spectacular", # OpenAPI 3.0 documentation "corsheaders", # CORS headers for API "pghistory", # django-pghistory @@ -180,9 +186,9 @@ STORAGES = { CLOUDFLARE_IMAGES_ACCOUNT_ID = config("CLOUDFLARE_IMAGES_ACCOUNT_ID") CLOUDFLARE_IMAGES_API_TOKEN = config("CLOUDFLARE_IMAGES_API_TOKEN") CLOUDFLARE_IMAGES_ACCOUNT_HASH = config("CLOUDFLARE_IMAGES_ACCOUNT_HASH") -CLOUDFLARE_IMAGES_DOMAIN = config( - "CLOUDFLARE_IMAGES_DOMAIN", default="imagedelivery.net" -) +# CLOUDFLARE_IMAGES_DOMAIN should only be set if using a custom domain +# When not set, it defaults to imagedelivery.net with the correct URL format +# CLOUDFLARE_IMAGES_DOMAIN = config("CLOUDFLARE_IMAGES_DOMAIN", default=None) # Password validation AUTH_PASSWORD_VALIDATORS = [ @@ -228,7 +234,11 @@ AUTHENTICATION_BACKENDS = [ # django-allauth settings SITE_ID = 1 + +# CORRECTED: Django allauth still expects the old format with asterisks for required fields +# The deprecation warnings are from dj_rest_auth, not our configuration ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"] + ACCOUNT_LOGIN_METHODS = {"email", "username"} ACCOUNT_EMAIL_VERIFICATION = "mandatory" ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True @@ -292,8 +302,9 @@ FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com") # Django REST Framework Settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.TokenAuthentication", # Kept for backward compatibility ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", @@ -443,3 +454,44 @@ SESSION_COOKIE_AGE = 86400 # 24 hours # Cache middleware settings CACHE_MIDDLEWARE_SECONDS = 300 # 5 minutes CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki" + +# JWT Settings + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), # 1 hour + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), # 7 days + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "UPDATE_LAST_LOGIN": True, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": None, + "AUDIENCE": None, + "ISSUER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=60), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), +} + +# dj-rest-auth settings +REST_AUTH = { + "USE_JWT": True, + "JWT_AUTH_COOKIE": "thrillwiki-auth", + "JWT_AUTH_REFRESH_COOKIE": "thrillwiki-refresh", + "JWT_AUTH_SECURE": not DEBUG, # Use secure cookies in production + "JWT_AUTH_HTTPONLY": True, + "JWT_AUTH_SAMESITE": "Lax", + "JWT_AUTH_RETURN_EXPIRATION": True, + "JWT_TOKEN_CLAIMS_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", +} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 80e7edb9..9caf8273 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -60,6 +60,7 @@ dependencies = [ "celery>=5.5.3", "django-celery-beat>=2.8.1", "django-celery-results>=2.6.0", + "djangorestframework-simplejwt>=5.5.1", ] [dependency-groups] diff --git a/backend/test_avatar.txt b/backend/test_avatar.txt new file mode 100644 index 00000000..9897ba21 --- /dev/null +++ b/backend/test_avatar.txt @@ -0,0 +1 @@ +Testing image file validation diff --git a/backend/uv.lock b/backend/uv.lock index 51cf5663..2ac3d7cf 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -857,6 +857,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, ] +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + [[package]] name = "drf-spectacular" version = "0.28.0" @@ -2170,6 +2184,7 @@ dependencies = [ { name = "django-webpack-loader" }, { name = "django-widget-tweaks" }, { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, { name = "drf-spectacular" }, { name = "factory-boy" }, { name = "flake8" }, @@ -2239,6 +2254,7 @@ requires-dist = [ { name = "django-webpack-loader", specifier = ">=3.1.1" }, { name = "django-widget-tweaks", specifier = ">=1.5.0" }, { name = "djangorestframework", specifier = ">=3.14.0" }, + { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "drf-spectacular", specifier = ">=0.27.0" }, { name = "factory-boy", specifier = ">=3.3.3" }, { name = "flake8", specifier = ">=7.1.1" }, diff --git a/cline_docs/activeContext.md b/cline_docs/activeContext.md index 9a9890c9..df7194e3 100644 --- a/cline_docs/activeContext.md +++ b/cline_docs/activeContext.md @@ -1,6 +1,9 @@ c# Active Context ## Current Focus +- **COMPLETED: dj-rest-auth Deprecation Warning Cleanup**: Successfully removed all custom code and patches created to address third-party deprecation warnings, returning system to original state with only corrected ACCOUNT_SIGNUP_FIELDS configuration +- **COMPLETED: Social Provider Management System**: Successfully implemented comprehensive social provider connection/disconnection functionality with safety validation to prevent account lockout +- **COMPLETED: Enhanced Superuser Account Deletion Error Handling**: Successfully implemented comprehensive error handling for superuser account deletion requests with detailed logging, security monitoring, and improved user experience - **COMPLETED: Comprehensive User Model with Settings Endpoints**: Successfully implemented comprehensive user model with extensive settings endpoints covering all aspects of user account management - **COMPLETED: RideModel API Directory Structure Reorganization**: Successfully reorganized API directory structure to match nested URL organization with mandatory nested file structure - **COMPLETED: RideModel API Reorganization**: Successfully reorganized RideModel endpoints from separate top-level `/api/v1/ride-models/` to nested `/api/v1/rides/manufacturers///` structure @@ -36,6 +39,65 @@ c# Active Context - **Reviews Latest Endpoint**: Combined park and ride reviews feed, user avatar integration, content snippets, smart truncation, comprehensive user information, public access ## Recent Changes +**dj-rest-auth Deprecation Warning Cleanup - COMPLETED:** +- **Issue Identified**: Deprecation warnings from dj-rest-auth package about USERNAME_REQUIRED and EMAIL_REQUIRED settings being deprecated in favor of SIGNUP_FIELDS configuration +- **Root Cause**: Warnings originate from third-party dj-rest-auth package itself (GitHub Issue #684, PR #686), not from user configuration +- **Custom Code Removal**: Successfully removed all custom code and patches created to address the warnings: + - **Removed**: `backend/apps/api/v1/auth/serializers/registration.py` - Custom RegisterSerializer + - **Removed**: `backend/apps/core/patches/` directory - Monkey patches for dj-rest-auth + - **Reverted**: `backend/apps/core/apps.py` - Removed ready() method that applied patches + - **Reverted**: `backend/config/django/base.py` - Removed custom REGISTER_SERIALIZER configuration +- **Configuration Preserved**: Kept corrected ACCOUNT_SIGNUP_FIELDS format: `["email*", "username*", "password1*", "password2*"]` +- **Final State**: System returned to original state with deprecation warnings coming from third-party package as expected +- **User Acceptance**: User explicitly requested removal of all custom code with understanding that warnings cannot be eliminated from third-party dependencies +- **System Check**: ✅ Django system check passes with warnings now originating from dj-rest-auth package as expected + +**Social Provider Management System - COMPLETED:** +- **Service Layer**: Created `SocialProviderService` with comprehensive business logic + - Safety validation to prevent account lockout: Only allow removing last provider if another provider is connected OR email/password auth exists + - Methods: `can_disconnect_provider()`, `get_connected_providers()`, `disconnect_provider()`, `get_auth_status()` + - Critical safety rule implementation with detailed logging and error handling +- **API Endpoints**: Complete CRUD operations for social provider management + - GET `/auth/social/providers/available/` - List available providers (Google, Discord) + - GET `/auth/social/connected/` - List user's connected providers with provider details + - POST `/auth/social/connect//` - Connect new social provider to account + - DELETE `/auth/social/disconnect//` - Disconnect provider with safety validation + - GET `/auth/social/status/` - Get overall social authentication status and capabilities +- **Serializers**: Comprehensive data validation and transformation + - `ConnectedProviderSerializer` - Connected provider details with metadata + - `AvailableProviderSerializer` - Available provider information + - `SocialAuthStatusSerializer` - Overall authentication status + - `SocialProviderErrorSerializer` - Detailed error responses with suggestions + - Input/output serializers for all connect/disconnect operations +- **Safety Validation**: Comprehensive account lockout prevention + - Validates remaining authentication methods before allowing disconnection + - Checks for other connected social providers + - Verifies email/password authentication availability + - Detailed error messages with specific suggestions for users +- **Error Handling**: Comprehensive error scenarios with specific error codes + - `PROVIDER_NOT_CONNECTED` - Attempting to disconnect non-connected provider + - `LAST_AUTH_METHOD` - Preventing removal of last authentication method + - `PROVIDER_NOT_AVAILABLE` - Invalid provider specified + - `CONNECTION_FAILED` - Social provider connection failures +- **Files Created/Modified**: + - `backend/apps/accounts/services/social_provider_service.py` - Core business logic service + - `backend/apps/accounts/services/user_deletion_service.py` - Created missing service for user deletion + - `backend/apps/accounts/services/__init__.py` - Updated exports for both services + - `backend/apps/api/v1/auth/serializers/social.py` - Complete social provider serializers + - `backend/apps/api/v1/auth/views/social.py` - Social provider API views + - `backend/apps/api/v1/auth/urls.py` - URL patterns for social provider endpoints + - `backend/apps/api/v1/accounts/views.py` - Fixed UserDeletionService import + - `docs/frontend.md` - Complete API documentation with React examples + - `docs/types-api.ts` - TypeScript interfaces for social provider management + - `docs/lib-api.ts` - API functions for social provider operations +- **Django Integration**: Full integration with Django Allauth + - Works with existing Google and Discord social providers + - Maintains JWT authentication alongside social auth + - Proper user account linking and unlinking + - Session management and security considerations +- **Testing**: ✅ Django system check passes with no issues +- **Import Resolution**: ✅ All import issues resolved, UserDeletionService created and properly exported + **Comprehensive User Model with Settings Endpoints - COMPLETED:** - **Extended User Model**: Added 20+ new fields to User model including privacy settings, notification preferences, security settings, and detailed user preferences - **Database Migrations**: Successfully applied migrations for new User model fields with proper defaults @@ -250,6 +312,18 @@ c# Active Context - `backend/apps/api/v1/accounts/urls.py` - URL patterns for all new user settings endpoints - `docs/frontend.md` - Complete API documentation with TypeScript interfaces and usage examples +### Social Provider Management Files +- `backend/apps/accounts/services/social_provider_service.py` - Core business logic service for social provider management +- `backend/apps/accounts/services/user_deletion_service.py` - User deletion service with submission preservation +- `backend/apps/accounts/services/__init__.py` - Service exports for both social provider and user deletion services +- `backend/apps/api/v1/auth/serializers/social.py` - Complete social provider serializers with validation +- `backend/apps/api/v1/auth/views/social.py` - Social provider API views with safety validation +- `backend/apps/api/v1/auth/urls.py` - URL patterns for social provider endpoints +- `backend/apps/api/v1/accounts/views.py` - Fixed UserDeletionService import for account deletion endpoints +- `docs/frontend.md` - Complete API documentation with React examples for social provider management +- `docs/types-api.ts` - TypeScript interfaces for social provider management +- `docs/lib-api.ts` - API functions for social provider operations + ### Celery Integration Files - `backend/config/celery.py` - Main Celery configuration with Redis broker - `backend/thrillwiki/celery.py` - Celery app initialization and task autodiscovery @@ -369,6 +443,21 @@ c# Active Context - **Top Lists**: ✅ Full CRUD operations for user top lists - **Account Deletion**: ✅ Self-service deletion with email verification and submission preservation - **Frontend Documentation**: ✅ Complete TypeScript interfaces and usage examples in docs/frontend.md +- **Social Provider Management System**: ✅ Successfully implemented and tested + - **Service Layer**: ✅ SocialProviderService with comprehensive business logic and safety validation + - **Safety Validation**: ✅ Prevents account lockout by validating remaining authentication methods + - **API Endpoints**: ✅ Complete CRUD operations for social provider management + - GET `/auth/social/providers/available/` - ✅ Lists available providers (Google, Discord) + - GET `/auth/social/connected/` - ✅ Lists user's connected providers with details + - POST `/auth/social/connect//` - ✅ Connects new social provider to account + - DELETE `/auth/social/disconnect//` - ✅ Disconnects provider with safety validation + - GET `/auth/social/status/` - ✅ Returns overall social authentication status + - **Error Handling**: ✅ Comprehensive error scenarios with specific error codes and user-friendly messages + - **Django Integration**: ✅ Full integration with Django Allauth for Google and Discord providers + - **Import Resolution**: ✅ All import issues resolved, UserDeletionService created and properly exported + - **System Check**: ✅ Django system check passes with no issues + - **Documentation**: ✅ Complete API documentation with React examples and TypeScript types + - **Frontend Integration**: ✅ TypeScript interfaces and API functions ready for frontend implementation - **Reviews Latest Endpoint**: ✅ Successfully implemented and tested - **Endpoint**: GET `/api/v1/reviews/latest/` - ✅ Returns combined feed of park and ride reviews - **Default Behavior**: ✅ Returns 8 reviews with default limit (20) diff --git a/docs/avatar-upload-debugging.md b/docs/avatar-upload-debugging.md new file mode 100644 index 00000000..2a14a8a0 --- /dev/null +++ b/docs/avatar-upload-debugging.md @@ -0,0 +1,107 @@ +# Avatar Upload Debugging Guide + +Last Updated: 2025-08-29 + +## Issue Summary + +The avatar upload functionality was experiencing file corruption during transmission from the frontend to the backend. PNG files were being corrupted where the PNG header `\x89PNG` was being replaced with UTF-8 replacement characters `\xef\xbf\xbdPNG`. + +## Root Cause Analysis + +### Next.js Proxy Issue (ACTUAL ROOT CAUSE) +The primary issue was in the Next.js API proxy route that converts binary FormData to text: + +**File:** `/Users/talor/dyad-apps/thrillwiki-real/src/app/api/[...path]/route.ts` + +1. **Problematic code:** + ```typescript + body = await clonedRequest.text(); // This corrupts binary data! + ``` + +2. **Why this causes corruption:** + - FormData containing binary image files gets converted to UTF-8 text + - Binary bytes like `\x89` (PNG signature) become UTF-8 replacement characters `\xef\xbf\xbd` + - This mangles the file data before it even reaches the backend + + +## Solutions Implemented + +### Next.js Proxy Fix (PRIMARY FIX) +**File:** `/Users/talor/dyad-apps/thrillwiki-real/src/app/api/[...path]/route.ts` + +Fixed the proxy to preserve binary data instead of converting to text: + +```typescript +// BEFORE (problematic) +body = await clonedRequest.text(); // Corrupts binary data! + +// AFTER (fixed) +const arrayBuffer = await clonedRequest.arrayBuffer(); +body = new Uint8Array(arrayBuffer); // Preserves binary data +``` + +**Why this works:** +- `arrayBuffer()` preserves the original binary data +- `Uint8Array` maintains the exact byte sequence +- No UTF-8 text conversion that corrupts binary files + +### Backend Cleanup +**File:** `/Users/talor/thrillwiki_django_no_react/backend/apps/api/v1/serializers/accounts.py` + +Simplified the avatar validation to basic file validation only: + +```python +# Removed complex corruption repair logic +# Now just validates file size, format, and dimensions with PIL +def validate_avatar(self, value): + # Basic file validation only + # Let Cloudflare Images handle the rest +``` + +## Technical Details + +### API Flow +1. **Frontend:** `AvatarUploader` → `useAuth.uploadAvatar()` → `accountApi.uploadAvatar(file)` +2. **Next.js Proxy:** Preserves binary data using `arrayBuffer()` instead of `text()` +3. **Backend:** `AvatarUploadView` → `AvatarUploadSerializer.validate_avatar()` → Cloudflare Images API + +## Testing + +### Manual Testing Steps +1. Select a PNG image file in the avatar uploader +2. Upload the file +3. Verify the upload succeeds without corruption +4. Check that the avatar displays correctly + + +## Prevention + +### Frontend Best Practices +- Always pass `FormData` objects directly to API calls +- Avoid extracting and re-wrapping files unnecessarily +- Use proper file validation on the client side + +### Backend Best Practices +- Use Django's proper file upload classes (`InMemoryUploadedFile`, `TemporaryUploadedFile`) +- Implement robust file validation and corruption detection +- Provide detailed logging for debugging file upload issues + +## Related Files + +### Frontend +- `/Users/talor/dyad-apps/thrillwiki-real/src/components/profile/avatar-uploader.tsx` +- `/Users/talor/dyad-apps/thrillwiki-real/src/hooks/use-auth.tsx` +- `/Users/talor/dyad-apps/thrillwiki-real/src/lib/api.ts` + +### Backend +- `/Users/talor/thrillwiki_django_no_react/backend/apps/api/v1/serializers/accounts.py` +- `/Users/talor/thrillwiki_django_no_react/backend/apps/api/v1/accounts/views.py` +- `/Users/talor/thrillwiki_django_no_react/backend/apps/accounts/services.py` + +## Status + +✅ **RESOLVED** - File corruption issue fixed in Next.js proxy +✅ **CLEANED UP** - Removed unnecessary corruption repair code from backend +✅ **DOCUMENTED** - Comprehensive debugging guide created + +The avatar upload functionality should now work correctly without file corruption. The Next.js proxy properly preserves binary data, and the backend performs basic file validation only. diff --git a/docs/frontend.md b/docs/frontend.md index f4eaca75..003c7c5c 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -6,18 +6,33 @@ This document provides comprehensive documentation for all ThrillWiki API endpoi ## Authentication -All API requests require authentication via JWT tokens. Include the token in the Authorization header: +ThrillWiki uses JWT Bearer token authentication. After successful login or signup, you'll receive access and refresh tokens that must be included in subsequent API requests. + +### Authentication Headers + +Include the access token in the Authorization header using Bearer format: ```typescript headers: { - 'Authorization': `Bearer ${token}`, + 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } ``` +### Token Management + +- **Access Token**: Short-lived token (1 hour) used for API requests +- **Refresh Token**: Long-lived token (7 days) used to obtain new access tokens +- **Token Rotation**: Refresh tokens are rotated on each refresh for enhanced security + ## Base URL -All API endpoints are prefixed with `/api/v1/` +The frontend uses a Next.js proxy that routes API requests: +- Frontend requests: `/v1/auth/login/`, `/v1/accounts/profile/`, etc. +- Proxy adds `/api/` prefix: `/api/v1/auth/login/`, `/api/v1/accounts/profile/`, etc. +- Backend receives: `/api/v1/auth/login/`, `/api/v1/accounts/profile/`, etc. + +**Important**: Frontend code should make requests to `/v1/...` endpoints, not `/api/v1/...` ## Moderation System API @@ -271,19 +286,389 @@ The moderation system provides comprehensive content moderation, user management ### Login - **POST** `/api/v1/auth/login/` -- **Body**: `{ "username": string, "password": string }` +- **Body**: `{ "username": string, "password": string, "turnstile_token"?: string }` - **Returns**: JWT tokens and user data +- **Response**: + ```typescript + { + "access": string, + "refresh": string, + "user": { + "id": number, + "username": string, + "email": string, + "display_name": string, + "is_active": boolean, + "date_joined": string + }, + "message": string + } + ``` ### Signup - **POST** `/api/v1/auth/signup/` -- **Body**: User registration data +- **Body**: + ```typescript + { + "username": string, + "email": string, + "password": string, + "password_confirm": string, + "display_name": string, // Required field + "turnstile_token"?: string + } + ``` +- **Returns**: JWT tokens and user data +- **Response**: Same format as login response (access and refresh tokens) +- **Note**: `display_name` is now required during registration. The system no longer uses separate first_name and last_name fields. + +### Token Refresh +- **POST** `/api/v1/auth/token/refresh/` +- **Body**: `{ "refresh": string }` +- **Returns**: New access token and optionally a new refresh token +- **Response**: + ```typescript + { + "access": string, + "refresh"?: string // Only returned if refresh token rotation is enabled + } + ``` + +### Social Authentication + +#### Google Login +- **POST** `/api/v1/auth/social/google/` +- **Body**: `{ "access_token": string }` +- **Returns**: JWT tokens and user data (same format as regular login) +- **Note**: The access_token should be obtained from Google OAuth flow + +#### Discord Login +- **POST** `/api/v1/auth/social/discord/` +- **Body**: `{ "access_token": string }` +- **Returns**: JWT tokens and user data (same format as regular login) +- **Note**: The access_token should be obtained from Discord OAuth flow + +#### Connect Social Account +- **POST** `/api/v1/auth/social/{provider}/connect/` +- **Permissions**: Authenticated users only +- **Body**: `{ "access_token": string }` +- **Returns**: `{ "success": boolean, "message": string }` +- **Note**: Links a social account to an existing ThrillWiki account + +#### Disconnect Social Account +- **POST** `/api/v1/auth/social/{provider}/disconnect/` +- **Permissions**: Authenticated users only +- **Returns**: `{ "success": boolean, "message": string }` + +#### Get Social Connections +- **GET** `/api/v1/auth/social/connections/` +- **Permissions**: Authenticated users only +- **Returns**: + ```typescript + { + "google": { "connected": boolean, "email"?: string }, + "discord": { "connected": boolean, "username"?: string } + } + ``` + +### Social Provider Management + +#### List Available Providers +- **GET** `/api/v1/auth/social/providers/available/` +- **Permissions**: Public access +- **Returns**: List of available social providers for connection +- **Response**: + ```typescript + { + "available_providers": [ + { + "id": "google", + "name": "Google", + "auth_url": "https://example.com/accounts/google/login/", + "connect_url": "https://example.com/api/v1/auth/social/connect/google/" + } + ], + "count": number + } + ``` + +#### List Connected Providers +- **GET** `/api/v1/auth/social/connected/` +- **Permissions**: Authenticated users only +- **Returns**: List of social providers connected to user's account +- **Response**: + ```typescript + { + "connected_providers": [ + { + "provider": "google", + "provider_name": "Google", + "uid": "user_id_on_provider", + "date_joined": "2025-01-01T00:00:00Z", + "can_disconnect": boolean, + "disconnect_reason": string | null, + "extra_data": object + } + ], + "count": number, + "has_password_auth": boolean, + "can_disconnect_any": boolean + } + ``` + +#### Connect Social Provider +- **POST** `/api/v1/auth/social/connect/{provider}/` +- **Permissions**: Authenticated users only +- **Parameters**: `provider` - Provider ID (e.g., 'google', 'discord') +- **Returns**: Connection initiation response with auth URL +- **Response**: + ```typescript + { + "success": boolean, + "message": string, + "provider": string, + "auth_url": string + } + ``` +- **Error Responses**: + - `400`: Provider already connected or invalid provider + - `500`: Connection initiation failed + +#### Disconnect Social Provider +- **DELETE** `/api/v1/auth/social/disconnect/{provider}/` +- **Permissions**: Authenticated users only +- **Parameters**: `provider` - Provider ID to disconnect +- **Returns**: Disconnection result with safety information +- **Response**: + ```typescript + { + "success": boolean, + "message": string, + "provider": string, + "remaining_providers": string[], + "has_password_auth": boolean, + "suggestions"?: string[] + } + ``` +- **Error Responses**: + - `400`: Cannot disconnect (safety validation failed) + - `404`: Provider not connected + - `500`: Disconnection failed + +#### Get Social Authentication Status +- **GET** `/api/v1/auth/social/status/` +- **Permissions**: Authenticated users only +- **Returns**: Comprehensive social authentication status +- **Response**: + ```typescript + { + "user_id": number, + "username": string, + "email": string, + "has_password_auth": boolean, + "connected_providers": ConnectedProvider[], + "total_auth_methods": number, + "can_disconnect_any": boolean, + "requires_password_setup": boolean + } + ``` + +### Social Provider Safety Rules + +The social provider management system enforces strict safety rules to prevent users from locking themselves out: + +1. **Disconnection Safety**: Users can only disconnect a social provider if they have: + - Another social provider connected, OR + - Email + password authentication set up + +2. **Error Scenarios**: + - **Only Provider + No Password**: "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first." + - **No Password Auth**: "Please set up email/password authentication before disconnecting this provider." + +3. **Suggested Actions**: When disconnection is blocked, the API provides suggestions: + - "Set up password authentication" + - "Connect another social provider" + +### Usage Examples + +#### Check Social Provider Status +```typescript +const checkSocialStatus = async () => { + try { + const response = await fetch('/api/v1/auth/social/status/', { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (data.requires_password_setup) { + // Show password setup prompt + showPasswordSetupModal(); + } + + return data; + } catch (error) { + console.error('Failed to get social status:', error); + } +}; +``` + +#### Connect Social Provider +```typescript +const connectProvider = async (provider: string) => { + try { + const response = await fetch(`/api/v1/auth/social/connect/${provider}/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (data.success) { + // Redirect to provider auth URL + window.location.href = data.auth_url; + } + } catch (error) { + console.error('Failed to connect provider:', error); + } +}; +``` + +#### Disconnect Social Provider with Safety Check +```typescript +const disconnectProvider = async (provider: string) => { + try { + const response = await fetch(`/api/v1/auth/social/disconnect/${provider}/`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (!response.ok) { + if (response.status === 400) { + // Show safety warning with suggestions + showSafetyWarning(data.error, data.suggestions); + } + return; + } + + // Success - update UI + toast.success(data.message); + refreshConnectedProviders(); + + } catch (error) { + console.error('Failed to disconnect provider:', error); + } +}; +``` + +#### React Component Example +```typescript +import { useState, useEffect } from 'react'; + +interface SocialProvider { + provider: string; + provider_name: string; + can_disconnect: boolean; + disconnect_reason?: string; +} + +const SocialProviderManager: React.FC = () => { + const [connectedProviders, setConnectedProviders] = useState([]); + const [hasPasswordAuth, setHasPasswordAuth] = useState(false); + + useEffect(() => { + loadConnectedProviders(); + }, []); + + const loadConnectedProviders = async () => { + const response = await fetch('/api/v1/auth/social/connected/', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + const data = await response.json(); + + setConnectedProviders(data.connected_providers); + setHasPasswordAuth(data.has_password_auth); + }; + + const handleDisconnect = async (provider: string) => { + const response = await fetch(`/api/v1/auth/social/disconnect/${provider}/`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) { + const error = await response.json(); + alert(error.error + '\n\nSuggestions:\n' + error.suggestions?.join('\n')); + return; + } + + loadConnectedProviders(); // Refresh list + }; + + return ( +
+

Connected Social Accounts

+ + {!hasPasswordAuth && connectedProviders.length === 1 && ( +
+

+ ⚠️ Set up a password to safely manage your social connections +

+
+ )} + + {connectedProviders.map((provider) => ( +
+ {provider.provider_name} + + +
+ ))} +
+ ); +}; +``` ### Logout - **POST** `/api/v1/auth/logout/` +- **Returns**: `{ "message": string }` ### Current User - **GET** `/api/v1/auth/user/` - **Returns**: Current user profile data +- **Response**: + ```typescript + { + "id": number, + "username": string, + "email": string, + "display_name": string, + "is_active": boolean, + "date_joined": string + } + ``` ### Password Reset - **POST** `/api/v1/auth/password/reset/` @@ -293,6 +678,121 @@ The moderation system provides comprehensive content moderation, user management - **POST** `/api/v1/auth/password/change/` - **Body**: `{ "old_password": string, "new_password": string }` +## User Account Management API + +### User Profile +- **GET** `/api/v1/accounts/profile/` +- **Permissions**: Authenticated users only +- **Returns**: Complete user profile including account details, preferences, and statistics + +### Update Account +- **PATCH** `/api/v1/accounts/profile/account/` +- **Permissions**: Authenticated users only +- **Body**: `{ "display_name"?: string, "email"?: string }` + +### Update Profile +- **PATCH** `/api/v1/accounts/profile/update/` +- **Permissions**: Authenticated users only +- **Body**: `{ "display_name"?: string, "pronouns"?: string, "bio"?: string, "twitter"?: string, "instagram"?: string, "youtube"?: string, "discord"?: string }` + +### Avatar Upload +- **POST** `/api/v1/accounts/profile/avatar/upload/` +- **Permissions**: Authenticated users only +- **Content-Type**: `multipart/form-data` +- **Body**: FormData with `avatar` field containing image file (JPEG, PNG, WebP) +- **Returns**: + ```typescript + { + "success": boolean, + "message": string, + "avatar_url": string, + "avatar_variants": { + "thumbnail": string, // 64x64 + "avatar": string, // 200x200 + "large": string // 400x400 + } + } + ``` + +**⚠️ CRITICAL AUTHENTICATION REQUIREMENT**: +- This endpoint requires authentication via JWT token in Authorization header +- **Common Issue**: "Authentication credentials were not provided" (401 error) +- **Root Cause**: User not logged in or JWT token not being sent +- **Debug Steps**: See `docs/avatar-upload-debugging.md` for comprehensive troubleshooting guide + +**Usage Example**: +```typescript +// Ensure user is logged in first +const { user } = useAuth(); +if (!user) { + // Redirect to login + return; +} + +// Upload avatar +const file = event.target.files[0]; +const response = await accountApi.uploadAvatar(file); +``` + +**Test Credentials** (for debugging): +- Username: `testuser` +- Password: `testpass123` +- Email: `test@example.com` + +**Recent Fix (2025-08-29)**: +- Fixed file corruption issue where PNG headers were being corrupted during upload +- Frontend now passes FormData directly instead of extracting and re-wrapping files +- Backend includes corruption detection and repair mechanism +- See `docs/avatar-upload-debugging.md` for complete technical details + +### Avatar Delete +- **DELETE** `/api/v1/accounts/profile/avatar/delete/` +- **Permissions**: Authenticated users only +- **Returns**: `{ "success": boolean, "message": string, "avatar_url": string }` + +### User Preferences +- **GET** `/api/v1/accounts/preferences/` +- **PATCH** `/api/v1/accounts/preferences/update/` +- **Permissions**: Authenticated users only + +### Notification Settings +- **GET** `/api/v1/accounts/settings/notifications/` +- **PATCH** `/api/v1/accounts/settings/notifications/update/` +- **Permissions**: Authenticated users only + +### Privacy Settings +- **GET** `/api/v1/accounts/settings/privacy/` +- **PATCH** `/api/v1/accounts/settings/privacy/update/` +- **Permissions**: Authenticated users only + +### Security Settings +- **GET** `/api/v1/accounts/settings/security/` +- **PATCH** `/api/v1/accounts/settings/security/update/` +- **Permissions**: Authenticated users only + +### User Statistics +- **GET** `/api/v1/accounts/statistics/` +- **Permissions**: Authenticated users only +- **Returns**: User activity statistics, ride credits, contributions, and achievements + +### Top Lists +- **GET** `/api/v1/accounts/top-lists/` +- **POST** `/api/v1/accounts/top-lists/create/` +- **PATCH** `/api/v1/accounts/top-lists/{id}/` +- **DELETE** `/api/v1/accounts/top-lists/{id}/delete/` +- **Permissions**: Authenticated users only + +### Notifications +- **GET** `/api/v1/accounts/notifications/` +- **PATCH** `/api/v1/accounts/notifications/mark-read/` +- **Permissions**: Authenticated users only + +### Account Deletion +- **POST** `/api/v1/accounts/delete-account/request/` +- **POST** `/api/v1/accounts/delete-account/verify/` +- **POST** `/api/v1/accounts/delete-account/cancel/` +- **Permissions**: Authenticated users only + ## Statistics API ### Global Statistics @@ -314,7 +814,9 @@ The moderation system provides comprehensive content moderation, user management ## Error Handling -All API endpoints return standardized error responses: +All API endpoints return standardized error responses. The system provides enhanced error handling with detailed messages, error codes, and contextual information. + +### Standard Error Response Format ```typescript interface ApiError { @@ -329,12 +831,274 @@ interface ApiError { } ``` -Common error codes: +### Enhanced Error Response Format + +For critical operations like account deletion, the API returns enhanced error responses with additional context: + +```typescript +interface EnhancedApiError { + status: "error"; + error: { + code: string; + message: string; + error_code: string; + user_info?: { + username: string; + role: string; + is_superuser: boolean; + is_staff: boolean; + }; + help_text?: string; + details?: any; + request_user?: string; + }; + data: null; +} +``` + +### Error Handling in React Components + +Here's how to handle and display enhanced error messages in your NextJS components: + +```typescript +import { useState } from 'react'; +import { toast } from 'react-hot-toast'; + +interface ErrorDisplayProps { + error: EnhancedApiError | null; + onDismiss: () => void; +} + +const ErrorDisplay: React.FC = ({ error, onDismiss }) => { + if (!error) return null; + + const { error: errorData } = error; + + return ( +
+
+
+ +
+
+

+ {errorData.message} +

+ + {errorData.error_code && ( +

+ Error Code: {errorData.error_code} +

+ )} + + {errorData.user_info && ( +
+

User: {errorData.user_info.username} ({errorData.user_info.role})

+ {errorData.user_info.is_superuser && ( +

⚠️ Superuser Account

+ )} +
+ )} + + {errorData.help_text && ( +
+ Help: {errorData.help_text} +
+ )} +
+ +
+
+ ); +}; + +// Usage in account deletion component +const AccountDeletionForm: React.FC = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleDeleteAccount = async () => { + setIsLoading(true); + setError(null); + + try { + await api.accounts.requestAccountDeletion(); + toast.success('Account deletion request submitted successfully'); + } catch (err: any) { + if (err.response?.data) { + const apiError = err.response.data as EnhancedApiError; + setError(apiError); + + // Also show toast for immediate feedback + toast.error(apiError.error.message); + + // Log security-related errors for monitoring + if (apiError.error.error_code === 'SUPERUSER_DELETION_BLOCKED') { + console.warn('Superuser deletion attempt blocked:', { + user: apiError.error.user_info?.username, + timestamp: new Date().toISOString() + }); + } + } else { + setError({ + status: "error", + error: { + code: "UNKNOWN_ERROR", + message: "An unexpected error occurred", + error_code: "UNKNOWN_ERROR" + }, + data: null + }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+ setError(null)} + /> + + +
+ ); +}; +``` + +### Toast Notifications for Errors + +For immediate user feedback, combine detailed error displays with toast notifications: + +```typescript +import { toast } from 'react-hot-toast'; + +const handleApiError = (error: EnhancedApiError) => { + const { error: errorData } = error; + + // Show immediate toast + toast.error(errorData.message, { + duration: 5000, + position: 'top-right', + }); + + // For critical errors, show additional context + if (errorData.error_code === 'SUPERUSER_DELETION_BLOCKED') { + toast.error('Superuser accounts cannot be deleted for security reasons', { + duration: 8000, + icon: '🔒', + }); + } + + if (errorData.error_code === 'ADMIN_DELETION_BLOCKED') { + toast.error('Admin accounts with staff privileges cannot be deleted', { + duration: 8000, + icon: '⚠️', + }); + } +}; +``` + +### Error Boundary for Global Error Handling + +Create an error boundary to catch and display API errors globally: + +```typescript +import React from 'react'; + +interface ErrorBoundaryState { + hasError: boolean; + error: EnhancedApiError | null; +} + +class ApiErrorBoundary extends React.Component< + React.PropsWithChildren<{}>, + ErrorBoundaryState +> { + constructor(props: React.PropsWithChildren<{}>) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: any): ErrorBoundaryState { + if (error.response?.data?.status === 'error') { + return { + hasError: true, + error: error.response.data as EnhancedApiError + }; + } + return { hasError: true, error: null }; + } + + render() { + if (this.state.hasError && this.state.error) { + return ( + this.setState({ hasError: false, error: null })} + /> + ); + } + + return this.props.children; + } +} +``` + +### Common Error Codes + +The system uses specific error codes for different scenarios: + - `NOT_AUTHENTICATED`: User not logged in - `PERMISSION_DENIED`: Insufficient permissions - `NOT_FOUND`: Resource not found - `VALIDATION_ERROR`: Invalid request data - `RATE_LIMITED`: Too many requests +- `SUPERUSER_DELETION_BLOCKED`: Superuser account deletion attempt +- `ADMIN_DELETION_BLOCKED`: Admin account with staff privileges deletion attempt +- `ACCOUNT_DELETION_FAILED`: General account deletion failure +- `SECURITY_VIOLATION`: Security policy violation detected + +### Error Logging and Monitoring + +For production applications, implement error logging: + +```typescript +const logError = (error: EnhancedApiError, context: string) => { + // Log to your monitoring service (e.g., Sentry, LogRocket) + console.error(`API Error in ${context}:`, { + code: error.error.code, + message: error.error.message, + errorCode: error.error.error_code, + userInfo: error.error.user_info, + timestamp: new Date().toISOString(), + context + }); + + // Send to analytics if it's a security-related error + if (error.error.error_code?.includes('DELETION_BLOCKED')) { + // Track security events + analytics.track('Security Event', { + event: 'Account Deletion Blocked', + errorCode: error.error.error_code, + user: error.error.user_info?.username + }); + } +}; +``` ## Pagination diff --git a/docs/lib-api.ts b/docs/lib-api.ts index 346843fa..10cf8a18 100644 --- a/docs/lib-api.ts +++ b/docs/lib-api.ts @@ -1,729 +1,2261 @@ // ThrillWiki API Client for NextJS Frontend -// Last updated: 2025-08-29 -// This file contains the complete API client implementation for ThrillWiki +// This file contains all API endpoint functions with full type safety -import { - ApiResponse, +import type { + // Authentication Types + LoginRequest, + LoginResponse, + SignupRequest, + SignupResponse, + LogoutResponse, + CurrentUserResponse, + PasswordResetRequest, + PasswordResetResponse, + PasswordChangeRequest, + PasswordChangeResponse, + SocialProvidersResponse, + AuthStatusResponse, + + // Social Provider Management Types + ConnectedProvider, + AvailableProvider, + SocialAuthStatus, + + // External API Types (merged from thrillwiki-real) + Stats, + TrendingItem, + Park, + Ride, + Review, + Company, PaginatedResponse, - // Moderation types + FilterOptions, + User, + RegisterRequest, + + // Content Moderation Types ModerationReport, - CreateModerationReportData, + ModerationReportsResponse, + CreateModerationReport, + ModerationQueueItem, + ModerationQueueResponse, + UpdateModerationReport, UpdateModerationReportData, - ModerationQueue, - CompleteQueueItemData, ModerationAction, - CreateModerationActionData, - BulkOperation, - CreateBulkOperationData, - UserModerationProfile, - ModerationStatsData, - // Filter types ModerationReportFilters, ModerationQueueFilters, ModerationActionFilters, - BulkOperationFilters, - ParkFilters, - RideFilters, + ModerationStatsData, + AssignQueueItemData, + CompleteQueueItemData, + CreateModerationActionData, + + // User Moderation Types + UserModerationProfile, + UserModerationAction, + UserModerationActionResponse, + UserModerationStats, + + // Bulk Operations Types + BulkOperation, + BulkOperationsResponse, + CreateBulkOperation, + BulkUpdateParks, + BulkUpdateRides, + BulkImportData, + BulkExportData, + BulkModerateContent, + BulkUserActions, + BulkOperationResult, + + // Park Reviews Types + ParkReview, + ParkReviewsResponse, + CreateParkReview, + UpdateParkReview, + ParkReviewVote, + ParkReviewVoteResponse, + UploadParkReviewPhoto, + ParkReviewFilters, + + // User Account Management Types + CompleteUserProfile, + AccountUpdate, + ProfileUpdate, + AvatarUpload, + AvatarUploadResponse, + AvatarDeleteResponse, + UserPreferences, + ThemeUpdate, + NotificationSettings, + PrivacySettings, + SecuritySettings, + SecuritySettingsUpdate, + UserStatistics, + TopList, + TopListsResponse, + CreateTopList, + NotificationResponse, + MarkNotificationsRead, + MarkReadResponse, + NotificationPreferences, + DeletionRequest, + VerifyDeletion, + DeletionComplete, + CancelDeletion, + DeletionEligibility, + + // Parks API Types + ParkListResponse, + ParkSummary, + CreateParkRequest, + ParkDetail, + ParkFilterOptions, + ParkImageSettings, + ParkPhotosResponse, + UploadParkPhoto, + UpdateParkPhoto, + + // Rides API Types + RideListResponse, + RideSummary, + CreateRideRequest, + RideDetail, + RideFilterOptions, + RideImageSettings, + RidePhotosResponse, + UploadRidePhoto, + UpdateRidePhoto, + ManufacturerRideModels, + + // Search & Core API Types + CompanySearchResponse, + RideModelSearchResponse, + SearchSuggestionsResponse, + EntitySearchResponse, + EntityNotFoundRequest, + EntityNotFoundResponse, + EntitySuggestionsResponse, + + // Maps API Types + MapLocationsResponse, + LocationDetail, + MapSearchResponse, + MapStatsResponse, + MapCacheResponse, + CacheInvalidateResponse, + + // Health & Statistics Types + HealthCheckResponse, + SimpleHealthResponse, + PerformanceMetricsResponse, + SystemStatsResponse, + StatsRecalculateResponse, + + // Trending & Discovery Types + TrendingResponse, + NewContentResponse, + TriggerTrendingResponse, + + // Reviews & Rankings Types + LatestReviewsResponse, + ReviewSummary, + RankingsResponse, + RideRankingDetail, + RankingCalculationResponse, + + // Email Service Types + ContactEmailRequest, + ContactEmailResponse, + NewsletterSubscribeRequest, + NewsletterSubscribeResponse, + NewsletterUnsubscribeRequest, + NewsletterUnsubscribeResponse, + + // History API Types + EntityHistoryResponse, + RecentChangesResponse, + + // Utility Types SearchFilters, - // Entity types - Park, - Ride, - Manufacturer, - RideModel, - ParkPhoto, - RidePhoto, - RideReview, - CreateRideReviewData, - // Auth types - LoginData, - SignupData, - AuthResponse, - UserProfile, - PasswordResetData, - PasswordChangeData, - // Stats types - GlobalStats, - TrendingContent, - // Utility types - ApiClientConfig, - RequestConfig, + BoundingBox, + + // Queue Routing Response Types + QueueRoutingResponse, + AutoApprovedResponse, + QueuedResponse, + FailedResponse, + EditSubmission, + PhotoSubmission, + QueueConfiguration, } from '@/types/api'; // ============================================================================ -// API Client Configuration +// API Configuration // ============================================================================ -const DEFAULT_CONFIG: ApiClientConfig = { - baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1', +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/v1'; + +interface ApiConfig { + baseURL: string; + timeout: number; + headers: Record; +} + +const defaultConfig: ApiConfig = { + baseURL: API_BASE_URL, timeout: 30000, - retries: 3, - retryDelay: 1000, headers: { 'Content-Type': 'application/json', }, }; +// Helper function from external API (merged from thrillwiki-real) +const createQuery = (params: object): string => { + const filteredParams = Object.entries(params).reduce((acc, [key, value]) => { + if (value !== null && value !== undefined && value !== '') { + acc[key] = value; + } + return acc; + }, {} as { [key: string]: any }); + + const query = new URLSearchParams(filteredParams).toString(); + return query ? `?${query}` : ''; +}; + // ============================================================================ -// HTTP Client Class +// HTTP Client Utilities // ============================================================================ -class HttpClient { - private config: ApiClientConfig; - private authToken: string | null = null; - - constructor(config: Partial = {}) { - this.config = { ...DEFAULT_CONFIG, ...config }; +class ApiError extends Error { + constructor( + message: string, + public status: number, + public response?: any + ) { + super(message); + this.name = 'ApiError'; } +} - setAuthToken(token: string | null) { - this.authToken = token; - } +async function makeRequest( + endpoint: string, + options: RequestInit = {}, + config: Partial = {} +): Promise { + const finalConfig = { ...defaultConfig, ...config }; + const url = `${finalConfig.baseURL}${endpoint}`; + + // Don't set Content-Type for FormData - let browser handle it + const isFormData = options.body instanceof FormData; + const baseHeaders = isFormData ? {} : finalConfig.headers; + + // Add auth token if available + const token = getAuthToken(); + const authHeaders: Record = token ? { 'Authorization': `Bearer ${token}` } : {}; + + const finalOptions: RequestInit = { + ...options, + headers: { + ...baseHeaders, + ...authHeaders, + ...(options.headers as Record || {}), + }, + }; - private getHeaders(customHeaders: Record = {}): Record { - const headers = { ...this.config.headers, ...customHeaders }; + try { + const response = await fetch(url, finalOptions); - if (this.authToken) { - headers.Authorization = `Bearer ${this.authToken}`; + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new ApiError( + errorData.detail || `HTTP ${response.status}: ${response.statusText}`, + response.status, + errorData + ); } - return headers; - } - - private async makeRequest(config: RequestConfig): Promise> { - const url = `${this.config.baseURL}${config.url}`; - const headers = this.getHeaders(config.headers); - - const requestConfig: RequestInit = { - method: config.method, - headers, - signal: AbortSignal.timeout(config.timeout || this.config.timeout), - }; - - if (config.data && ['POST', 'PUT', 'PATCH'].includes(config.method)) { - requestConfig.body = JSON.stringify(config.data); + // Handle 204 No Content responses + if (response.status === 204) { + return {} as T; } - // Add query parameters for GET requests - const finalUrl = config.params && config.method === 'GET' - ? `${url}?${new URLSearchParams(config.params).toString()}` - : url; - - try { - const response = await fetch(finalUrl, requestConfig); - const data = await response.json(); - - if (!response.ok) { - return { - status: 'error', - data: null, - error: data.error || { - code: 'HTTP_ERROR', - message: `HTTP ${response.status}: ${response.statusText}`, - }, - }; - } - - return data; - } catch (error) { - return { - status: 'error', - data: null, - error: { - code: 'NETWORK_ERROR', - message: error instanceof Error ? error.message : 'Network request failed', - }, - }; + return await response.json(); + } catch (error) { + if (error instanceof ApiError) { + throw error; } + throw new ApiError('Network error', 0, error); } +} - async get(url: string, params?: Record, headers?: Record): Promise> { - return this.makeRequest({ method: 'GET', url, params, headers }); +function getAuthToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('authToken'); +} + +function setAuthToken(token: string): void { + if (typeof window !== 'undefined') { + localStorage.setItem('authToken', token); } +} - async post(url: string, data?: any, headers?: Record): Promise> { - return this.makeRequest({ method: 'POST', url, data, headers }); +function removeAuthToken(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem('authToken'); } +} - async put(url: string, data?: any, headers?: Record): Promise> { - return this.makeRequest({ method: 'PUT', url, data, headers }); +function getRefreshToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('refreshToken'); +} + +function setRefreshToken(token: string): void { + if (typeof window !== 'undefined') { + localStorage.setItem('refreshToken', token); } +} - async patch(url: string, data?: any, headers?: Record): Promise> { - return this.makeRequest({ method: 'PATCH', url, data, headers }); - } - - async delete(url: string, headers?: Record): Promise> { - return this.makeRequest({ method: 'DELETE', url, headers }); +function removeRefreshToken(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem('refreshToken'); } } // ============================================================================ -// API Client Class +// Authentication API // ============================================================================ -export class ThrillWikiApiClient { - private http: HttpClient; - - constructor(config?: Partial) { - this.http = new HttpClient(config); - } - - setAuthToken(token: string | null) { - this.http.setAuthToken(token); - } - - // ============================================================================ - // Authentication API - // ============================================================================ - - auth = { - login: async (data: LoginData): Promise> => { - return this.http.post('/auth/login/', data); - }, - - signup: async (data: SignupData): Promise> => { - return this.http.post('/auth/signup/', data); - }, - - logout: async (): Promise> => { - return this.http.post('/auth/logout/'); - }, - - getCurrentUser: async (): Promise> => { - return this.http.get('/auth/user/'); - }, - - resetPassword: async (data: PasswordResetData): Promise> => { - return this.http.post('/auth/password/reset/', data); - }, - - changePassword: async (data: PasswordChangeData): Promise> => { - return this.http.post('/auth/password/change/', data); - }, - - getAuthStatus: async (): Promise> => { - return this.http.get('/auth/status/'); - }, - - getSocialProviders: async (): Promise> => { - return this.http.get('/auth/providers/'); - }, - }; - - // ============================================================================ - // Moderation API - // ============================================================================ - - moderation = { - // Reports - reports: { - list: async (filters?: ModerationReportFilters): Promise>> => { - return this.http.get>('/moderation/reports/', filters); - }, - - create: async (data: CreateModerationReportData): Promise> => { - return this.http.post('/moderation/reports/', data); - }, - - get: async (id: number): Promise> => { - return this.http.get(`/moderation/reports/${id}/`); - }, - - update: async (id: number, data: Partial): Promise> => { - return this.http.patch(`/moderation/reports/${id}/`, data); - }, - - delete: async (id: number): Promise> => { - return this.http.delete(`/moderation/reports/${id}/`); - }, - - assign: async (id: number, moderatorId: number): Promise> => { - return this.http.post(`/moderation/reports/${id}/assign/`, { moderator_id: moderatorId }); - }, - - resolve: async (id: number, resolutionAction: string, resolutionNotes?: string): Promise> => { - return this.http.post(`/moderation/reports/${id}/resolve/`, { - resolution_action: resolutionAction, - resolution_notes: resolutionNotes || '', - }); - }, - - getStats: async (): Promise> => { - return this.http.get('/moderation/reports/stats/'); - }, - }, - - // Queue - queue: { - list: async (filters?: ModerationQueueFilters): Promise>> => { - return this.http.get>('/moderation/queue/', filters); - }, - - create: async (data: Partial): Promise> => { - return this.http.post('/moderation/queue/', data); - }, - - get: async (id: number): Promise> => { - return this.http.get(`/moderation/queue/${id}/`); - }, - - update: async (id: number, data: Partial): Promise> => { - return this.http.patch(`/moderation/queue/${id}/`, data); - }, - - delete: async (id: number): Promise> => { - return this.http.delete(`/moderation/queue/${id}/`); - }, - - assign: async (id: number, moderatorId: number): Promise> => { - return this.http.post(`/moderation/queue/${id}/assign/`, { moderator_id: moderatorId }); - }, - - unassign: async (id: number): Promise> => { - return this.http.post(`/moderation/queue/${id}/unassign/`); - }, - - complete: async (id: number, data: CompleteQueueItemData): Promise> => { - return this.http.post(`/moderation/queue/${id}/complete/`, data); - }, - - getMyQueue: async (): Promise>> => { - return this.http.get>('/moderation/queue/my_queue/'); - }, - }, - - // Actions - actions: { - list: async (filters?: ModerationActionFilters): Promise>> => { - return this.http.get>('/moderation/actions/', filters); - }, - - create: async (data: CreateModerationActionData): Promise> => { - return this.http.post('/moderation/actions/', data); - }, - - get: async (id: number): Promise> => { - return this.http.get(`/moderation/actions/${id}/`); - }, - - update: async (id: number, data: Partial): Promise> => { - return this.http.patch(`/moderation/actions/${id}/`, data); - }, - - delete: async (id: number): Promise> => { - return this.http.delete(`/moderation/actions/${id}/`); - }, - - deactivate: async (id: number): Promise> => { - return this.http.post(`/moderation/actions/${id}/deactivate/`); - }, - - getActive: async (): Promise>> => { - return this.http.get>('/moderation/actions/active/'); - }, - - getExpired: async (): Promise>> => { - return this.http.get>('/moderation/actions/expired/'); - }, - }, - - // Bulk Operations - bulkOperations: { - list: async (filters?: BulkOperationFilters): Promise>> => { - return this.http.get>('/moderation/bulk-operations/', filters); - }, - - create: async (data: CreateBulkOperationData): Promise> => { - return this.http.post('/moderation/bulk-operations/', data); - }, - - get: async (id: string): Promise> => { - return this.http.get(`/moderation/bulk-operations/${id}/`); - }, - - update: async (id: string, data: Partial): Promise> => { - return this.http.patch(`/moderation/bulk-operations/${id}/`, data); - }, - - delete: async (id: string): Promise> => { - return this.http.delete(`/moderation/bulk-operations/${id}/`); - }, - - cancel: async (id: string): Promise> => { - return this.http.post(`/moderation/bulk-operations/${id}/cancel/`); - }, - - retry: async (id: string): Promise> => { - return this.http.post(`/moderation/bulk-operations/${id}/retry/`); - }, - - getLogs: async (id: string): Promise> => { - return this.http.get(`/moderation/bulk-operations/${id}/logs/`); - }, - - getRunning: async (): Promise>> => { - return this.http.get>('/moderation/bulk-operations/running/'); - }, - }, - - // User Moderation - users: { - get: async (id: number): Promise> => { - return this.http.get(`/moderation/users/${id}/`); - }, - - moderate: async (id: number, data: CreateModerationActionData): Promise> => { - return this.http.post(`/moderation/users/${id}/moderate/`, data); - }, - - search: async (params: { query?: string; role?: string; has_restrictions?: boolean }): Promise>> => { - return this.http.get>('/moderation/users/search/', params); - }, - - getStats: async (): Promise> => { - return this.http.get('/moderation/users/stats/'); - }, - }, - }; - - // ============================================================================ - // Parks API - // ============================================================================ - - parks = { - list: async (filters?: ParkFilters): Promise>> => { - return this.http.get>('/parks/', filters); - }, - - get: async (slug: string): Promise> => { - return this.http.get(`/parks/${slug}/`); - }, - - getRides: async (parkSlug: string, filters?: RideFilters): Promise>> => { - return this.http.get>(`/parks/${parkSlug}/rides/`, filters); - }, - - getPhotos: async (parkSlug: string, filters?: SearchFilters): Promise>> => { - return this.http.get>(`/parks/${parkSlug}/photos/`, filters); - }, - - // Park operators and owners - operators: { - list: async (filters?: SearchFilters): Promise>> => { - return this.http.get>('/parks/operators/', filters); - }, - - get: async (slug: string): Promise> => { - return this.http.get(`/parks/operators/${slug}/`); - }, - }, - - owners: { - list: async (filters?: SearchFilters): Promise>> => { - return this.http.get>('/parks/owners/', filters); - }, - - get: async (slug: string): Promise> => { - return this.http.get(`/parks/owners/${slug}/`); - }, - }, - }; - - // ============================================================================ - // Rides API - // ============================================================================ - - rides = { - list: async (filters?: RideFilters): Promise>> => { - return this.http.get>('/rides/', filters); - }, - - get: async (parkSlug: string, rideSlug: string): Promise> => { - return this.http.get(`/rides/${parkSlug}/${rideSlug}/`); - }, - - getPhotos: async (parkSlug: string, rideSlug: string, filters?: SearchFilters): Promise>> => { - return this.http.get>(`/rides/${parkSlug}/${rideSlug}/photos/`, filters); - }, - - getReviews: async (parkSlug: string, rideSlug: string, filters?: SearchFilters): Promise>> => { - return this.http.get>(`/rides/${parkSlug}/${rideSlug}/reviews/`, filters); - }, - - createReview: async (parkSlug: string, rideSlug: string, data: CreateRideReviewData): Promise> => { - return this.http.post(`/rides/${parkSlug}/${rideSlug}/reviews/`, data); - }, - - // Manufacturers - manufacturers: { - list: async (filters?: SearchFilters): Promise>> => { - return this.http.get>('/rides/manufacturers/', filters); - }, - - get: async (slug: string): Promise> => { - return this.http.get(`/rides/manufacturers/${slug}/`); - }, - - getRides: async (slug: string, filters?: RideFilters): Promise>> => { - return this.http.get>(`/rides/manufacturers/${slug}/rides/`, filters); - }, - - getModels: async (slug: string, filters?: SearchFilters): Promise>> => { - return this.http.get>(`/rides/manufacturers/${slug}/models/`, filters); - }, - }, - - // Designers - designers: { - list: async (filters?: SearchFilters): Promise>> => { - return this.http.get>('/rides/designers/', filters); - }, - - get: async (slug: string): Promise> => { - return this.http.get(`/rides/designers/${slug}/`); - }, - }, - - // Ride Models - models: { - list: async (filters?: SearchFilters): Promise>> => { - return this.http.get>('/rides/models/', filters); - }, - - get: async (manufacturerSlug: string, modelSlug: string): Promise> => { - return this.http.get(`/rides/models/${manufacturerSlug}/${modelSlug}/`); - }, - }, - }; - - // ============================================================================ - // Statistics API - // ============================================================================ - - stats = { - getGlobal: async (): Promise> => { - return this.http.get('/stats/'); - }, - - recalculate: async (): Promise> => { - return this.http.post('/stats/recalculate/'); - }, - - getTrending: async (params?: { content_type?: string; time_period?: string }): Promise> => { - return this.http.get('/trending/', params); - }, - - getNewContent: async (): Promise> => { - return this.http.get('/new-content/'); - }, - - triggerTrendingCalculation: async (): Promise> => { - return this.http.post('/trending/calculate/'); - }, - - getLatestReviews: async (params?: { limit?: number; park?: string; ride?: string }): Promise>> => { - return this.http.get>('/reviews/latest/', params); - }, - }; - - // ============================================================================ - // Rankings API - // ============================================================================ - - rankings = { - list: async (filters?: SearchFilters): Promise>> => { - return this.http.get>('/rankings/', filters); - }, - - calculate: async (): Promise> => { - return this.http.post('/rankings/calculate/'); - }, - }; - - // ============================================================================ - // Health Check API - // ============================================================================ - - health = { - check: async (): Promise> => { - return this.http.get('/health/'); - }, - - simple: async (): Promise> => { - return this.http.get('/health/simple/'); - }, - - performance: async (): Promise> => { - return this.http.get('/health/performance/'); - }, - }; - - // ============================================================================ - // Accounts API - // ============================================================================ - - accounts = { - getProfile: async (username: string): Promise> => { - return this.http.get(`/accounts/users/${username}/`); - }, - - updateProfile: async (data: Partial): Promise> => { - return this.http.patch('/accounts/profile/', data); - }, - - getSettings: async (): Promise> => { - return this.http.get('/accounts/settings/'); - }, - - updateSettings: async (data: any): Promise> => { - return this.http.patch('/accounts/settings/', data); - }, - }; - - // ============================================================================ - // Maps API - // ============================================================================ - - maps = { - getParkLocations: async (params?: { bounds?: string; zoom?: number }): Promise> => { - return this.http.get('/maps/park-locations/', params); - }, - - getRideLocations: async (parkSlug: string, params?: { bounds?: string; zoom?: number }): Promise> => { - return this.http.get(`/maps/parks/${parkSlug}/ride-locations/`, params); - }, - - getUnifiedMap: async (params?: { bounds?: string; zoom?: number; include_parks?: boolean; include_rides?: boolean }): Promise> => { - return this.http.get('/maps/unified/', params); - }, - }; - - // ============================================================================ - // Email API - // ============================================================================ - - email = { - sendTestEmail: async (data: { to: string; subject: string; message: string }): Promise> => { - return this.http.post('/email/send-test/', data); - }, - - getTemplates: async (): Promise> => { - return this.http.get('/email/templates/'); - }, - }; -} - -// ============================================================================ -// Default Export and Utilities -// ============================================================================ - -// Create default client instance -export const apiClient = new ThrillWikiApiClient(); - -// Utility functions for common operations -export const apiUtils = { - // Set authentication token for all requests - setAuthToken: (token: string | null) => { - apiClient.setAuthToken(token); - }, - - // Check if response is successful - isSuccess: (response: ApiResponse): response is ApiResponse & { status: 'success'; data: T } => { - return response.status === 'success' && response.data !== null; - }, - - // Check if response is an error - isError: (response: ApiResponse): response is ApiResponse & { status: 'error'; error: NonNullable['error']> } => { - return response.status === 'error' && response.error !== null; - }, - - // Extract data from successful response or throw error - unwrap: (response: ApiResponse): T => { - if (apiUtils.isSuccess(response)) { - return response.data; - } - throw new Error(response.error?.message || 'API request failed'); - }, - - // Handle paginated responses - extractPaginatedData: (response: ApiResponse>): T[] => { - if (apiUtils.isSuccess(response)) { - return response.data.results; - } - return []; - }, - - // Build query string from filters - buildQueryString: (filters: Record): string => { - const params = new URLSearchParams(); +export const authApi = { + async login(data: LoginRequest): Promise { + const response = await makeRequest('/auth/login/', { + method: 'POST', + body: JSON.stringify(data), + }); - Object.entries(filters).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - if (Array.isArray(value)) { - value.forEach(v => params.append(key, String(v))); - } else { - params.append(key, String(value)); - } - } + // Store access token on successful login + setAuthToken(response.access); + // Store refresh token separately + setRefreshToken(response.refresh); + return response; + }, + + async signup(data: SignupRequest): Promise { + const response = await makeRequest('/auth/signup/', { + method: 'POST', + body: JSON.stringify(data), + }); + + // Store access token on successful signup + setAuthToken(response.access); + // Store refresh token separately + setRefreshToken(response.refresh); + return response; + }, + + async refreshToken(): Promise<{ access: string; refresh: string }> { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + throw new ApiError('No refresh token available', 401); + } + + const response = await makeRequest<{ access: string; refresh: string }>('/auth/token/refresh/', { + method: 'POST', + body: JSON.stringify({ refresh: refreshToken }), }); - return params.toString(); + // Update stored tokens + setAuthToken(response.access); + setRefreshToken(response.refresh); + return response; }, - // Format error message for display - formatError: (error: ApiResponse['error']): string => { - if (!error) return 'Unknown error occurred'; + async logout(): Promise { + const refreshToken = getRefreshToken(); + const requestBody = refreshToken ? { refresh: refreshToken } : {}; - if (error.details && typeof error.details === 'object') { - // Handle validation errors - const fieldErrors = Object.entries(error.details) - .map(([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : messages}`) - .join('; '); - - return fieldErrors || error.message; - } - - return error.message; - }, - - // Check if user has required role - hasRole: (user: UserProfile | null, requiredRole: UserProfile['role']): boolean => { - if (!user) return false; + const response = await makeRequest('/auth/logout/', { + method: 'POST', + body: JSON.stringify(requestBody), + }); - const roleHierarchy = ['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER']; - const userRoleIndex = roleHierarchy.indexOf(user.role); - const requiredRoleIndex = roleHierarchy.indexOf(requiredRole); + // Remove tokens on successful logout + removeAuthToken(); + removeRefreshToken(); + return response; + }, + + async getCurrentUser(): Promise { + return makeRequest('/auth/user/'); + }, + + async resetPassword(data: PasswordResetRequest): Promise { + return makeRequest('/auth/password/reset/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async changePassword(data: PasswordChangeRequest): Promise { + return makeRequest('/auth/password/change/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getSocialProviders(): Promise { + return makeRequest('/auth/providers/'); + }, + + async checkAuthStatus(): Promise { + return makeRequest('/auth/status/', { + method: 'POST', + }); + }, + + // Social Authentication Methods + async googleLogin(accessToken: string): Promise { + const response = await makeRequest('/auth/social/google/', { + method: 'POST', + body: JSON.stringify({ access_token: accessToken }), + }); - return userRoleIndex >= requiredRoleIndex; + // Store JWT tokens on successful social login + setAuthToken(response.access); + setRefreshToken(response.refresh); + return response; }, - // Check if user can moderate - canModerate: (user: UserProfile | null): boolean => { - return apiUtils.hasRole(user, 'MODERATOR'); + async discordLogin(accessToken: string): Promise { + const response = await makeRequest('/auth/social/discord/', { + method: 'POST', + body: JSON.stringify({ access_token: accessToken }), + }); + + // Store JWT tokens on successful social login + setAuthToken(response.access); + setRefreshToken(response.refresh); + return response; }, - // Check if user is admin - isAdmin: (user: UserProfile | null): boolean => { - return apiUtils.hasRole(user, 'ADMIN'); + async connectSocialAccount(provider: 'google' | 'discord', accessToken: string): Promise<{ success: boolean; message: string }> { + return makeRequest(`/auth/social/${provider}/connect/`, { + method: 'POST', + body: JSON.stringify({ access_token: accessToken }), + }); + }, + + async disconnectSocialAccount(provider: 'google' | 'discord'): Promise<{ success: boolean; message: string }> { + return makeRequest(`/auth/social/${provider}/disconnect/`, { + method: 'POST', + }); + }, + + async getSocialConnections(): Promise<{ + google: { connected: boolean; email?: string }; + discord: { connected: boolean; username?: string }; + }> { + return makeRequest('/auth/social/connections/'); + }, + + // Social Provider Management Methods + async getAvailableProviders(): Promise { + return makeRequest('/auth/social/providers/available/'); + }, + + async getConnectedProviders(): Promise { + return makeRequest('/auth/social/connected/'); + }, + + async connectProvider(provider: string, data: { access_token: string }): Promise<{ success: boolean; message: string; provider: ConnectedProvider }> { + return makeRequest(`/auth/social/connect/${provider}/`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async disconnectProvider(provider: string): Promise<{ success: boolean; message: string }> { + return makeRequest(`/auth/social/disconnect/${provider}/`, { + method: 'POST', + }); + }, + + async getSocialAuthStatus(): Promise { + return makeRequest('/auth/social/status/'); }, }; -// Export types for convenience -export type { - ApiResponse, - PaginatedResponse, - ModerationReport, - ModerationQueue, - ModerationAction, - BulkOperation, - UserModerationProfile, - Park, - Ride, - Manufacturer, - RideModel, - UserProfile, - GlobalStats, - TrendingContent, -} from './types-api'; +// ============================================================================ +// User Account Management API +// ============================================================================ -export default apiClient; +export const accountApi = { + async getProfile(): Promise { + return makeRequest('/accounts/profile/'); + }, + + async updateAccount(data: AccountUpdate): Promise { + return makeRequest('/accounts/profile/account/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async updateProfile(data: ProfileUpdate): Promise { + return makeRequest('/accounts/profile/update/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append('avatar', file); + + // Get auth token to ensure it's available + const token = getAuthToken(); + if (!token) { + throw new ApiError('Authentication required for avatar upload', 401); + } + + return makeRequest('/auth/user/avatar/', { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + }, + + // Backward compatibility alias + async updateAvatar(formData: FormData): Promise { + // Get auth token to ensure it's available + const token = getAuthToken(); + if (!token) { + throw new ApiError('Authentication required for avatar upload', 401); + } + + return makeRequest('/auth/user/avatar/', { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + }, + + async deleteAvatar(): Promise { + return makeRequest('/auth/user/avatar/', { + method: 'DELETE', + }); + }, + + async getPreferences(): Promise { + return makeRequest('/accounts/preferences/'); + }, + + async updatePreferences(data: Partial): Promise { + return makeRequest('/accounts/preferences/update/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async updateTheme(data: ThemeUpdate): Promise { + return makeRequest('/accounts/preferences/theme/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async getNotificationSettings(): Promise { + return makeRequest('/accounts/settings/notifications/'); + }, + + async updateNotificationSettings(data: Partial): Promise { + return makeRequest('/accounts/settings/notifications/update/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async getPrivacySettings(): Promise { + return makeRequest('/accounts/settings/privacy/'); + }, + + async updatePrivacySettings(data: Partial): Promise { + return makeRequest('/accounts/settings/privacy/update/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async getSecuritySettings(): Promise { + return makeRequest('/accounts/settings/security/'); + }, + + async updateSecuritySettings(data: SecuritySettingsUpdate): Promise { + return makeRequest('/accounts/settings/security/update/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async getStatistics(): Promise { + return makeRequest('/accounts/statistics/'); + }, + + async getTopLists(): Promise { + return makeRequest('/accounts/top-lists/'); + }, + + async createTopList(data: CreateTopList): Promise { + return makeRequest('/accounts/top-lists/create/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async updateTopList(listId: number, data: Partial): Promise { + return makeRequest(`/accounts/top-lists/${listId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async deleteTopList(listId: number): Promise { + return makeRequest(`/accounts/top-lists/${listId}/delete/`, { + method: 'DELETE', + }); + }, + + async getNotifications(params?: { + unread_only?: boolean; + notification_type?: string; + limit?: number; + offset?: number; + }): Promise { + const searchParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/accounts/notifications/${query ? `?${query}` : ''}`); + }, + + async markNotificationsRead(data: MarkNotificationsRead): Promise { + return makeRequest('/accounts/notifications/mark-read/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async getNotificationPreferences(): Promise { + return makeRequest('/accounts/notification-preferences/'); + }, + + async updateNotificationPreferences(data: Partial): Promise { + return makeRequest('/accounts/notification-preferences/update/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + // Account deletion with enhanced error handling for superuser/admin accounts + async requestAccountDeletion(): Promise { + return makeRequest('/accounts/delete-account/request/', { + method: 'POST', + }); + }, + + async verifyAccountDeletion(data: VerifyDeletion): Promise { + return makeRequest('/accounts/delete-account/verify/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async cancelAccountDeletion(): Promise { + return makeRequest('/accounts/delete-account/cancel/', { + method: 'POST', + }); + }, + + // Admin endpoints + async deleteUser(userId: string): Promise { + return makeRequest(`/accounts/users/${userId}/delete/`, { + method: 'DELETE', + }); + }, + + async checkUserDeletionEligibility(userId: string): Promise { + return makeRequest(`/accounts/users/${userId}/deletion-check/`); + }, +}; + +// ============================================================================ +// Parks API +// ============================================================================ + +export const parksApi = { + async getParks(params?: { + page?: number; + page_size?: number; + search?: string; + country?: string; + state?: string; + ordering?: string; + }): Promise { + const searchParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/parks/${query ? `?${query}` : ''}`); + }, + + async createPark(data: CreateParkRequest): Promise { + return makeRequest('/parks/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getPark(parkId: number): Promise { + return makeRequest(`/parks/${parkId}/`); + }, + + async updatePark(parkId: number, data: Partial): Promise { + return makeRequest(`/parks/${parkId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async deletePark(parkId: number): Promise { + return makeRequest(`/parks/${parkId}/`, { + method: 'DELETE', + }); + }, + + async getFilterOptions(): Promise { + return makeRequest('/parks/filter-options/'); + }, + + async searchCompanies(query: string): Promise { + return makeRequest(`/parks/search/companies/?q=${encodeURIComponent(query)}`); + }, + + async getSearchSuggestions(query: string): Promise { + return makeRequest(`/parks/search-suggestions/?q=${encodeURIComponent(query)}`); + }, + + async setParkImages(parkId: number, data: ParkImageSettings): Promise { + return makeRequest(`/parks/${parkId}/image-settings/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async getParkPhotos(parkId: number, params?: { + page?: number; + page_size?: number; + photo_type?: string; + }): Promise { + const searchParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/parks/${parkId}/photos/${query ? `?${query}` : ''}`); + }, + + async uploadParkPhoto(parkId: number, data: UploadParkPhoto): Promise { + const formData = new FormData(); + formData.append('image', data.image); + if (data.caption) formData.append('caption', data.caption); + if (data.photo_type) formData.append('photo_type', data.photo_type); + + return makeRequest(`/parks/${parkId}/photos/`, { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + }, + + async updateParkPhoto(parkId: number, photoId: number, data: UpdateParkPhoto): Promise { + return makeRequest(`/parks/${parkId}/photos/${photoId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async deleteParkPhoto(parkId: number, photoId: number): Promise { + return makeRequest(`/parks/${parkId}/photos/${photoId}/`, { + method: 'DELETE', + }); + }, +}; + +// ============================================================================ +// Rides API +// ============================================================================ + +export const ridesApi = { + async getRides(filters?: SearchFilters): Promise { + const searchParams = new URLSearchParams(); + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v.toString())); + } else { + searchParams.append(key, value.toString()); + } + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/rides/${query ? `?${query}` : ''}`); + }, + + async createRide(data: CreateRideRequest): Promise { + return makeRequest('/rides/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getRide(rideId: number): Promise { + return makeRequest(`/rides/${rideId}/`); + }, + + async updateRide(rideId: number, data: Partial): Promise { + return makeRequest(`/rides/${rideId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async deleteRide(rideId: number): Promise { + return makeRequest(`/rides/${rideId}/`, { + method: 'DELETE', + }); + }, + + async getFilterOptions(): Promise { + return makeRequest('/rides/filter-options/'); + }, + + async searchCompanies(query: string): Promise { + return makeRequest(`/rides/search/companies/?q=${encodeURIComponent(query)}`); + }, + + async searchRideModels(query: string): Promise { + return makeRequest(`/rides/search/ride-models/?q=${encodeURIComponent(query)}`); + }, + + async getSearchSuggestions(query: string): Promise { + return makeRequest(`/rides/search-suggestions/?q=${encodeURIComponent(query)}`); + }, + + async setRideImages(rideId: number, data: RideImageSettings): Promise { + return makeRequest(`/rides/${rideId}/image-settings/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async getRidePhotos(rideId: number, params?: { + page?: number; + page_size?: number; + photo_type?: string; + }): Promise { + const searchParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/rides/${rideId}/photos/${query ? `?${query}` : ''}`); + }, + + async uploadRidePhoto(rideId: number, data: UploadRidePhoto): Promise { + const formData = new FormData(); + formData.append('image', data.image); + if (data.caption) formData.append('caption', data.caption); + if (data.photo_type) formData.append('photo_type', data.photo_type); + + return makeRequest(`/rides/${rideId}/photos/`, { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + }, + + async updateRidePhoto(rideId: number, photoId: number, data: UpdateRidePhoto): Promise { + return makeRequest(`/rides/${rideId}/photos/${photoId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async deleteRidePhoto(rideId: number, photoId: number): Promise { + return makeRequest(`/rides/${rideId}/photos/${photoId}/`, { + method: 'DELETE', + }); + }, + + async getManufacturerRideModels(manufacturerSlug: string): Promise { + return makeRequest(`/rides/manufacturers/${manufacturerSlug}/`); + }, +}; + +// ============================================================================ +// Core/Search API +// ============================================================================ + +export const coreApi = { + async searchEntities(params: { + q: string; + entity_types?: string[]; + limit?: number; + }): Promise { + const searchParams = new URLSearchParams(); + searchParams.append('q', params.q); + + if (params.entity_types) { + params.entity_types.forEach(type => searchParams.append('entity_types', type)); + } + + if (params.limit) { + searchParams.append('limit', params.limit.toString()); + } + + return makeRequest(`/core/entities/search/?${searchParams.toString()}`); + }, + + async reportEntityNotFound(data: EntityNotFoundRequest): Promise { + return makeRequest('/core/entities/not-found/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getEntitySuggestions(params: { + q: string; + limit?: number; + }): Promise { + const searchParams = new URLSearchParams(); + searchParams.append('q', params.q); + + if (params.limit) { + searchParams.append('limit', params.limit.toString()); + } + + return makeRequest(`/core/entities/suggestions/?${searchParams.toString()}`); + }, +}; + +// ============================================================================ +// Maps API +// ============================================================================ + +export const mapsApi = { + async getMapLocations(params?: { + bounds?: string; + zoom?: number; + entity_types?: string[]; + categories?: string[]; + }): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v)); + } else { + searchParams.append(key, value.toString()); + } + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/maps/locations/${query ? `?${query}` : ''}`); + }, + + async getLocationDetail(locationType: 'park' | 'ride', locationId: number): Promise { + return makeRequest(`/maps/locations/${locationType}/${locationId}/`); + }, + + async searchMap(params: { + q: string; + bounds?: string; + entity_types?: string[]; + }): Promise { + const searchParams = new URLSearchParams(); + searchParams.append('q', params.q); + + if (params.bounds) { + searchParams.append('bounds', params.bounds); + } + + if (params.entity_types) { + params.entity_types.forEach(type => searchParams.append('entity_types', type)); + } + + return makeRequest(`/maps/search/?${searchParams.toString()}`); + }, + + async getMapBounds(bounds: BoundingBox & { zoom?: number }): Promise { + const searchParams = new URLSearchParams(); + searchParams.append('sw_lat', bounds.sw_lat.toString()); + searchParams.append('sw_lng', bounds.sw_lng.toString()); + searchParams.append('ne_lat', bounds.ne_lat.toString()); + searchParams.append('ne_lng', bounds.ne_lng.toString()); + + if (bounds.zoom) { + searchParams.append('zoom', bounds.zoom.toString()); + } + + return makeRequest(`/maps/bounds/?${searchParams.toString()}`); + }, + + async getMapStats(): Promise { + return makeRequest('/maps/stats/'); + }, + + async getMapCache(): Promise { + return makeRequest('/maps/cache/'); + }, + + async invalidateMapCache(): Promise { + return makeRequest('/maps/cache/invalidate/', { + method: 'POST', + }); + }, +}; + +// ============================================================================ +// Health & Statistics API +// ============================================================================ + +export const healthApi = { + async getHealthCheck(): Promise { + return makeRequest('/health/'); + }, + + async getSimpleHealth(): Promise { + return makeRequest('/health/simple/'); + }, + + async getPerformanceMetrics(): Promise { + return makeRequest('/health/performance/'); + }, + + async getSystemStats(): Promise { + return makeRequest('/stats/'); + }, + + async recalculateStats(): Promise { + return makeRequest('/stats/recalculate/', { + method: 'POST', + }); + }, +}; + +// ============================================================================ +// Trending & Discovery API +// ============================================================================ + +export const trendingApi = { + async getTrending(params?: { + time_period?: string; + entity_types?: string[]; + limit?: number; + }): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v)); + } else { + searchParams.append(key, value.toString()); + } + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/trending/${query ? `?${query}` : ''}`); + }, + + async getNewContent(params?: { + days?: number; + entity_types?: string[]; + limit?: number; + }): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v)); + } else { + searchParams.append(key, value.toString()); + } + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/new-content/${query ? `?${query}` : ''}`); + }, + + async triggerTrendingCalculation(): Promise { + return makeRequest('/trending/calculate/', { + method: 'POST', + }); + }, +}; + +// ============================================================================ +// Reviews & Rankings API +// ============================================================================ + +export const reviewsApi = { + async getLatestReviews(params?: { + limit?: number; + entity_types?: string[]; + min_rating?: number; + }): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v)); + } else { + searchParams.append(key, value.toString()); + } + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/reviews/latest/${query ? `?${query}` : ''}`); + }, +}; + +export const rankingsApi = { + async getRankings(params?: { + category?: string; + park_id?: number; + manufacturer_id?: number; + roller_coaster_type?: string; + track_material?: string; + min_rating?: number; + max_rating?: number; + min_reviews?: number; + ordering?: string; + limit?: number; + offset?: number; + }): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/rankings/${query ? `?${query}` : ''}`); + }, + + async getRideRanking(rideId: number): Promise { + return makeRequest(`/rankings/${rideId}/`); + }, + + async triggerRankingCalculation(): Promise { + return makeRequest('/rankings/calculate/', { + method: 'POST', + }); + }, +}; + +// ============================================================================ +// Email Service API +// ============================================================================ + +export const emailApi = { + async sendContactEmail(data: ContactEmailRequest): Promise { + return makeRequest('/email/contact/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async subscribeNewsletter(data: NewsletterSubscribeRequest): Promise { + return makeRequest('/email/newsletter/subscribe/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async unsubscribeNewsletter(data: NewsletterUnsubscribeRequest): Promise { + return makeRequest('/email/newsletter/unsubscribe/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, +}; + +// ============================================================================ +// Content Moderation API +// ============================================================================ + +export const moderationApi = { + // Moderation Reports + async getReports(params?: ModerationReportFilters): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/moderation/reports/${query ? `?${query}` : ''}`); + }, + + async createReport(data: CreateModerationReport): Promise { + return makeRequest('/moderation/reports/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getReport(reportId: number): Promise { + return makeRequest(`/moderation/reports/${reportId}/`); + }, + + async updateReport(reportId: number, data: UpdateModerationReportData): Promise { + return makeRequest(`/moderation/reports/${reportId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async deleteReport(reportId: number): Promise { + return makeRequest(`/moderation/reports/${reportId}/`, { + method: 'DELETE', + }); + }, + + async assignReport(reportId: number, moderatorId: number): Promise { + return makeRequest(`/moderation/reports/${reportId}/assign/`, { + method: 'POST', + body: JSON.stringify({ moderator_id: moderatorId }), + }); + }, + + async resolveReport(reportId: number, data: { + resolution_action: string; + resolution_notes?: string; + }): Promise { + return makeRequest(`/moderation/reports/${reportId}/resolve/`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getReportStats(): Promise { + return makeRequest('/moderation/reports/stats/'); + }, + + // Moderation Queue + async getQueue(params?: ModerationQueueFilters): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/moderation/queue/${query ? `?${query}` : ''}`); + }, + + async getMyQueue(): Promise { + return makeRequest('/moderation/queue/my_queue/'); + }, + + async assignQueueItem(itemId: number, data: AssignQueueItemData): Promise { + return makeRequest(`/moderation/queue/${itemId}/assign/`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async unassignQueueItem(itemId: number): Promise { + return makeRequest(`/moderation/queue/${itemId}/unassign/`, { + method: 'POST', + }); + }, + + async completeQueueItem(itemId: number, data: CompleteQueueItemData): Promise { + return makeRequest(`/moderation/queue/${itemId}/complete/`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + // Moderation Actions + async getActions(params?: ModerationActionFilters): Promise> { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest>(`/moderation/actions/${query ? `?${query}` : ''}`); + }, + + async createAction(data: CreateModerationActionData): Promise { + return makeRequest('/moderation/actions/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getAction(actionId: number): Promise { + return makeRequest(`/moderation/actions/${actionId}/`); + }, + + async deactivateAction(actionId: number): Promise { + return makeRequest(`/moderation/actions/${actionId}/deactivate/`, { + method: 'POST', + }); + }, + + async getActiveActions(): Promise> { + return makeRequest>('/moderation/actions/active/'); + }, + + async getExpiredActions(): Promise> { + return makeRequest>('/moderation/actions/expired/'); + }, +}; + +// ============================================================================ +// User Moderation API +// ============================================================================ + +export const userModerationApi = { + async getUserModerationProfile(userId: number): Promise { + return makeRequest(`/admin/users/${userId}/moderate/`); + }, + + async moderateUser(userId: number, action: UserModerationAction): Promise { + return makeRequest(`/admin/users/${userId}/moderate/`, { + method: 'POST', + body: JSON.stringify(action), + }); + }, + + async getUserModerationHistory(userId: number, params?: { + limit?: number; + offset?: number; + action_type?: "WARNING" | "CONTENT_REMOVAL" | "CONTENT_EDIT" | "USER_SUSPENSION" | "USER_BAN" | "ACCOUNT_RESTRICTION"; + }): Promise<{ results: ModerationAction[]; count: number }> { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/admin/users/${userId}/moderate/history/${query ? `?${query}` : ''}`); + }, + + async removeUserRestriction(userId: number, actionId: number): Promise<{ success: boolean; message: string }> { + return makeRequest(`/admin/users/${userId}/moderate/restrictions/${actionId}/remove/`, { + method: 'POST', + }); + }, + + async getModerationStats(): Promise { + return makeRequest('/admin/users/moderation-stats/'); + }, + + async searchUsersForModeration(params: { + query?: string; + role?: "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER"; + has_restrictions?: boolean; + registration_after?: string; + registration_before?: string; + last_activity_before?: string; + risk_level?: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + page?: number; + page_size?: number; + }): Promise<{ + count: number; + results: Array<{ + id: number; + username: string; + display_name: string; + email: string; + role: string; + date_joined: string; + last_login: string | null; + is_active: boolean; + restriction_count: number; + risk_level: string; + }>; + }> { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + + const query = searchParams.toString(); + return makeRequest(`/admin/users/search/${query ? `?${query}` : ''}`); + }, +}; + +// ============================================================================ +// Bulk Operations API +// ============================================================================ + +export const bulkOperationsApi = { + async getOperations(params?: { + status?: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED"; + operation_type?: "UPDATE_PARKS" | "UPDATE_RIDES" | "IMPORT_DATA" | "EXPORT_DATA" | "RECALCULATE_STATS" | "MODERATE_CONTENT" | "USER_ACTIONS"; + created_by?: number; + created_after?: string; + created_before?: string; + page?: number; + page_size?: number; + ordering?: string; + }): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/admin/bulk-operations/${query ? `?${query}` : ''}`); + }, + + async createOperation(data: CreateBulkOperation): Promise { + return makeRequest('/admin/bulk-operations/', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getOperation(operationId: string): Promise { + return makeRequest(`/admin/bulk-operations/${operationId}/`); + }, + + async cancelOperation(operationId: string): Promise<{ success: boolean; message: string }> { + return makeRequest(`/admin/bulk-operations/${operationId}/cancel/`, { + method: 'POST', + }); + }, + + async retryOperation(operationId: string): Promise { + return makeRequest(`/admin/bulk-operations/${operationId}/retry/`, { + method: 'POST', + }); + }, + + // Specific bulk operation helpers + async bulkUpdateParks(data: BulkUpdateParks): Promise { + return this.createOperation(data); + }, + + async bulkUpdateRides(data: BulkUpdateRides): Promise { + return this.createOperation(data); + }, + + async bulkImportData(data: BulkImportData): Promise { + return this.createOperation(data); + }, + + async bulkExportData(data: BulkExportData): Promise { + return this.createOperation(data); + }, + + async bulkModerateContent(data: BulkModerateContent): Promise { + return this.createOperation(data); + }, + + async bulkUserActions(data: BulkUserActions): Promise { + return this.createOperation(data); + }, + + // Operation monitoring + async getOperationLogs(operationId: string, params?: { + level?: "DEBUG" | "INFO" | "WARNING" | "ERROR"; + limit?: number; + offset?: number; + }): Promise<{ + logs: Array<{ + timestamp: string; + level: string; + message: string; + details?: any; + }>; + count: number; + }> { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/admin/bulk-operations/${operationId}/logs/${query ? `?${query}` : ''}`); + }, +}; + +// ============================================================================ +// Park Reviews API +// ============================================================================ + +export const parkReviewsApi = { + async getParkReviews(parkId: number, filters?: ParkReviewFilters): Promise { + const searchParams = new URLSearchParams(); + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v.toString())); + } else { + searchParams.append(key, value.toString()); + } + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/parks/${parkId}/reviews/${query ? `?${query}` : ''}`); + }, + + async createParkReview(parkId: number, data: CreateParkReview): Promise { + return makeRequest(`/parks/${parkId}/reviews/`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async getParkReview(parkId: number, reviewId: number): Promise { + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/`); + }, + + async updateParkReview(parkId: number, reviewId: number, data: UpdateParkReview): Promise { + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + + async deleteParkReview(parkId: number, reviewId: number): Promise { + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/`, { + method: 'DELETE', + }); + }, + + async voteParkReview(parkId: number, reviewId: number, vote: ParkReviewVote): Promise { + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/vote/`, { + method: 'POST', + body: JSON.stringify(vote), + }); + }, + + async removeParkReviewVote(parkId: number, reviewId: number): Promise { + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/vote/`, { + method: 'DELETE', + }); + }, + + async uploadParkReviewPhoto(parkId: number, reviewId: number, data: UploadParkReviewPhoto): Promise { + const formData = new FormData(); + formData.append('image', data.image); + if (data.caption) formData.append('caption', data.caption); + if (data.photo_type) formData.append('photo_type', data.photo_type); + + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/photos/`, { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + }, + + async deleteParkReviewPhoto(parkId: number, reviewId: number, photoId: number): Promise { + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/photos/${photoId}/`, { + method: 'DELETE', + }); + }, + + async reportParkReview(parkId: number, reviewId: number, data: { + reason: string; + description: string; + }): Promise<{ success: boolean; message: string }> { + return makeRequest(`/parks/${parkId}/reviews/${reviewId}/report/`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + // Park review statistics and analytics + async getParkReviewStats(parkId: number): Promise<{ + total_reviews: number; + average_rating: number; + rating_distribution: { + "1": number; "2": number; "3": number; "4": number; "5": number; + "6": number; "7": number; "8": number; "9": number; "10": number; + }; + category_averages: { + atmosphere: number; + cleanliness: number; + staff_friendliness: number; + value_for_money: number; + food_quality: number; + ride_variety: number; + }; + recent_trends: { + last_30_days_average: number; + trend_direction: "UP" | "DOWN" | "STABLE"; + trend_percentage: number; + }; + verified_visits_percentage: number; + photos_count: number; + }> { + return makeRequest(`/parks/${parkId}/reviews/stats/`); + }, +}; + +// ============================================================================ +// History API +// ============================================================================ + +export const historyApi = { + async getEntityHistory( + entityType: 'park' | 'ride', + entityId: number, + params?: { + limit?: number; + offset?: number; + field?: string; + } + ): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/history/${entityType}/${entityId}/${query ? `?${query}` : ''}`); + }, + + async getRecentChanges(params?: { + entity_types?: string[]; + actions?: string[]; + limit?: number; + days?: number; + }): Promise { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v)); + } else { + searchParams.append(key, value.toString()); + } + } + }); + } + + const query = searchParams.toString(); + return makeRequest(`/history/recent/${query ? `?${query}` : ''}`); + }, +}; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export const apiUtils = { + // Token management + getAuthToken, + setAuthToken, + removeAuthToken, + + // Error handling + isApiError: (error: any): error is ApiError => error instanceof ApiError, + + // URL building helpers + buildQueryString: (params: Record): string => { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, v.toString())); + } else { + searchParams.append(key, value.toString()); + } + } + }); + return searchParams.toString(); + }, + + // File upload helpers + createFormData: (data: Record): FormData => { + const formData = new FormData(); + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (value instanceof File) { + formData.append(key, value); + } else { + formData.append(key, value.toString()); + } + } + }); + return formData; + }, + + // Response helpers + handlePaginatedResponse: (response: any): { + data: T[]; + pagination: { + count: number; + next: string | null; + previous: string | null; + hasNext: boolean; + hasPrevious: boolean; + }; + } => ({ + data: response.results || [], + pagination: { + count: response.count || 0, + next: response.next || null, + previous: response.previous || null, + hasNext: !!response.next, + hasPrevious: !!response.previous, + }, + }), +}; + + +// ============================================================================ +// External API Client Class (merged from thrillwiki-real) +// ============================================================================ + +class ApiClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + private async fetchJson(endpoint: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, options) + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })) + throw new Error(errorData.message || `API error: ${response.status}`) + } + // Handle cases where the response body might be empty + const text = await response.text(); + return text ? JSON.parse(text) : ({} as T); + } + + async healthCheck(): Promise<{ status: string }> { + return this.fetchJson("/health/simple/") + } + + async getHealthDetail(): Promise { + return this.fetchJson('/health/detail/'); + } + + async getStats(): Promise { + return this.fetchJson("/stats/") + } + + async getTrendingContent(params: { timeframe: string; limit: number }): Promise<{ + trending_parks: TrendingItem[]; + trending_rides: TrendingItem[]; + }> { + return this.fetchJson(`/trending/${createQuery(params)}`) + } + + async getNewContent(params: { days: number; limit: number }): Promise<{ + new_parks: Park[]; + new_rides: Ride[]; + }> { + return this.fetchJson(`/new-content/${createQuery(params)}`) + } + + async getLatestReviews(params: { limit: number }): Promise { + return this.fetchJson(`/reviews/latest/${createQuery(params)}`) + } + + async searchEntities(params: { + query: string; + entity_types?: string[]; + include_suggestions?: boolean; + }): Promise<{ + matches: any[]; + suggestion?: string; + }> { + return this.fetchJson(`/search/${createQuery(params)}`) + } + + async getParks(params: object): Promise> { + return this.fetchJson(`/parks/${createQuery(params)}`); + } + + async getParkFilterOptions(): Promise { + return makeRequest('/parks/filters/'); + } + + // Rides methods + async getRides(params: object): Promise> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/rides/${query ? `?${query}` : ''}`); + } + + async getRideFilterOptions(): Promise { + return makeRequest('/rides/filters/'); + } + + // Companies methods + async getCompanies(params: object): Promise> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/companies/${query ? `?${query}` : ''}`); + } + + async getCompanyFilterOptions(): Promise { + return makeRequest('/companies/filters/'); + } + + async getMapLocations(params: object): Promise { + return this.fetchJson(`/map/locations/${createQuery(params)}`); + } + + async getRankings(params: object): Promise { + return this.fetchJson(`/rankings/${createQuery(params)}`); + } + + async getReviews(params: object): Promise> { + return this.fetchJson(`/reviews/${createQuery(params)}`); + } + + async searchCompanies(params: object): Promise { + return this.fetchJson(`/companies/search/${createQuery(params)}`); + } + + async getPerformanceMetrics(): Promise { + return this.fetchJson('/performance/'); + } + + async getTimeline(params: object): Promise { + return this.fetchJson(`/timeline/${createQuery(params)}`); + } + + async getEmailTemplates(): Promise { + return this.fetchJson('/admin/emails/'); + } + + // Auth methods + async checkAuthStatus(): Promise { + return this.fetchJson('/auth/status/'); + } + + async getCurrentUser(): Promise { + return this.fetchJson('/auth/user/'); + } + + async updateCurrentUser(userData: Partial): Promise { + return this.fetchJson('/auth/user/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userData), + }); + } + + // Backward compatibility alias - uses correct accounts endpoint + async updateAvatar(formData: FormData): Promise { + return makeRequest('/accounts/profile/avatar/upload/', { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + } + + async login(credentials: LoginRequest): Promise<{ user: User }> { + return this.fetchJson('/auth/login/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + } + + async register(userData: RegisterRequest): Promise<{ user: User }> { + return this.fetchJson('/auth/register/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userData), + }); + } + + async logout(): Promise { + await this.fetchJson('/auth/logout/', { method: 'POST' }); + } + + async changePassword(passwordData: object): Promise { + await this.fetchJson('/auth/password/change/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(passwordData), + }); + } + + async deleteAccount(): Promise { + await this.fetchJson('/auth/user/', { method: 'DELETE' }); + } + + async refreshToken(): Promise { + await this.fetchJson('/auth/token/refresh/', { method: 'POST' }); + } +} + +// Create instance matching external API pattern +export const apiClient = new ApiClient(API_BASE_URL); + +// ============================================================================ +// External API Client Methods (merged from thrillwiki-real) +// ============================================================================ + +export const externalApi = { + // Health and stats methods + async healthCheck(): Promise<{ status: string }> { + return makeRequest("/health/simple/"); + }, + + async getHealthDetail(): Promise { + return makeRequest('/health/detail/'); + }, + + async getStats(): Promise { + return makeRequest("/stats/"); + }, + + // Trending and discovery methods + async getTrendingContent(params: { timeframe: string; limit: number }): Promise<{ + trending_parks: TrendingItem[]; + trending_rides: TrendingItem[]; + }> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/trending/${query ? `?${query}` : ''}`); + }, + + async getNewContent(params: { days: number; limit: number }): Promise<{ + new_parks: Park[]; + new_rides: Ride[]; + }> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/new-content/${query ? `?${query}` : ''}`); + }, + + async getLatestReviews(params: { limit: number }): Promise { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/reviews/latest/${query ? `?${query}` : ''}`); + }, + + // Search methods + async searchEntities(params: { + query: string; + entity_types?: string[]; + include_suggestions?: boolean; + }): Promise<{ + matches: any[]; + suggestion?: string; + }> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/search/${query ? `?${query}` : ''}`); + }, + + // Parks methods + async getParks(params: object): Promise> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/parks/${query ? `?${query}` : ''}`); + }, + + async getParkFilterOptions(): Promise { + return makeRequest('/parks/filters/'); + }, + + // Rides methods + async getRides(params: object): Promise> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/rides/${query ? `?${query}` : ''}`); + }, + + async getRideFilterOptions(): Promise { + return makeRequest('/rides/filters/'); + }, + + // Companies methods + async getCompanies(params: object): Promise> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/companies/${query ? `?${query}` : ''}`); + }, + + async getCompanyFilterOptions(): Promise { + return makeRequest('/companies/filters/'); + }, + + async searchCompanies(params: object): Promise { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/companies/search/${query ? `?${query}` : ''}`); + }, + + // Additional utility methods + async getEntitySuggestions(params: object): Promise { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/search/suggest/${query ? `?${query}` : ''}`); + }, + + async getMapLocations(params: object): Promise { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/map/locations/${query ? `?${query}` : ''}`); + }, + + async getRankings(params: object): Promise { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/rankings/${query ? `?${query}` : ''}`); + }, + + async getReviews(params: object): Promise> { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/reviews/${query ? `?${query}` : ''}`); + }, + + async getPerformanceMetrics(): Promise { + return makeRequest('/performance/'); + }, + + async getTimeline(params: object): Promise { + const query = apiUtils.buildQueryString(params); + return makeRequest(`/timeline/${query ? `?${query}` : ''}`); + }, + + async getEmailTemplates(): Promise { + return makeRequest('/admin/emails/'); + }, + + // Auth methods from external API + async updateCurrentUser(userData: Partial): Promise { + return makeRequest('/auth/user/', { + method: 'PATCH', + body: JSON.stringify(userData), + }); + }, + + + async login(credentials: LoginRequest): Promise<{ user: User }> { + return makeRequest('/auth/login/', { + method: 'POST', + body: JSON.stringify(credentials), + }); + }, + + async register(userData: RegisterRequest): Promise<{ user: User }> { + return makeRequest('/auth/register/', { + method: 'POST', + body: JSON.stringify(userData), + }); + }, + + async deleteAccount(): Promise { + await makeRequest('/auth/user/', { method: 'DELETE' }); + }, + + async refreshToken(): Promise { + await makeRequest('/auth/token/refresh/', { method: 'POST' }); + }, +}; + +// Default export with all APIs +export default { + auth: authApi, + account: accountApi, + parks: parksApi, + rides: ridesApi, + core: coreApi, + maps: mapsApi, + health: healthApi, + trending: trendingApi, + reviews: reviewsApi, + rankings: rankingsApi, + email: emailApi, + history: historyApi, + moderation: moderationApi, + userModeration: userModerationApi, + bulkOperations: bulkOperationsApi, + parkReviews: parkReviewsApi, + external: externalApi, + utils: apiUtils, +}; + +// ============================================================================ +// Usage Examples +// ============================================================================ + +/* +// Authentication +import { authApi } from './api'; + +const login = async () => { + try { + const response = await authApi.login({ + username: 'user@example.com', + password: 'password123' + }); + console.log('Logged in:', response.user); + } catch (error) { + if (apiUtils.isApiError(error)) { + console.error('Login failed:', error.message); + } + } +}; + +// Signup with display_name +const signup = async () => { + try { + const response = await authApi.signup({ + username: 'newuser', + email: 'user@example.com', + password: 'password123', + password_confirm: 'password123', + display_name: 'New User' + }); + console.log('Signed up:', response.user); + } catch (error) { + if (apiUtils.isApiError(error)) { + console.error('Signup failed:', error.message); + } + } +}; + +// Parks with filtering +import { parksApi } from './api'; + +const getParksInUSA = async () => { + const parks = await parksApi.getParks({ + country: 'United States', + page_size: 50, + ordering: 'name' + }); + return parks.results; +}; + +// Rides with complex filtering +import { ridesApi } from './api'; + +const getRollerCoasters = async () => { + const rides = await ridesApi.getRides({ + categories: ['RC'], + min_height_ft: 100, + track_material: 'STEEL', + has_inversions: true, + ordering: '-average_rating' + }); + return rides.results; +}; + +// File upload +import { accountApi } from './api'; + +const uploadAvatar = async (file: File) => { + try { + const response = await accountApi.uploadAvatar(file); + console.log('Avatar uploaded:', response.avatar_url); + } catch (error) { + console.error('Upload failed:', error); + } +}; + +// Using default export +import api from './api'; + +const getUserProfile = async () => { + const profile = await api.account.getProfile(); + const stats = await api.account.getStatistics(); + return { profile, stats }; +}; +*/ diff --git a/docs/types-api.ts b/docs/types-api.ts index 5c96ad13..6e604f27 100644 --- a/docs/types-api.ts +++ b/docs/types-api.ts @@ -1,50 +1,1731 @@ -// ThrillWiki API Types for NextJS Frontend -// Last updated: 2025-08-29 -// This file contains all TypeScript interfaces for the ThrillWiki API +// Frontend TypeScript Types for ThrillWiki NextJS Application +// This file contains all TypeScript interfaces and types for the ThrillWiki API // ============================================================================ -// Base Types +// Base Interfaces (merged from thrillwiki-real) // ============================================================================ -export interface ApiResponse { - status: "success" | "error"; - data: T | null; - error: ApiError | null; +export interface ImageVariants { + thumbnail: string; + medium: string; + large: string; + avatar?: string; // Added for user avatars } -export interface ApiError { - code: string; +export interface Photo { + id: number; + image_variants: ImageVariants; + alt_text?: string; + image_url?: string; // Added for compatibility + caption?: string; + photo_type?: string; + uploaded_by?: { + id: number; + username: string; + display_name: string; + }; + uploaded_at?: string; +} + +export interface Location { + city: string; + state?: string; + country: string; + address?: string; + latitude?: number; + longitude?: number; +} + +export interface Entity { + id: number; + name: string; + slug: string; +} + +// ============================================================================ +// Authentication Types +// ============================================================================ + +export interface LoginRequest { + username: string; // Can be username or email + password: string; + turnstile_token?: string; // Optional Cloudflare Turnstile token +} + +export interface LoginResponse { + access: string; + refresh: string; + user: { + id: number; + username: string; + email: string; + display_name: string; + is_active: boolean; + date_joined: string; + }; message: string; - details?: any; - request_user?: string; } -export interface PaginatedResponse { - results: T[]; +export interface SignupRequest { + username: string; + email: string; + password: string; + password_confirm: string; + display_name: string; + turnstile_token?: string; // Optional Cloudflare Turnstile token +} + +export interface SignupResponse { + access: string; + refresh: string; + user: { + id: number; + username: string; + email: string; + display_name: string; + is_active: boolean; + date_joined: string; + }; + message: string; +} + +export interface TokenRefreshRequest { + refresh: string; +} + +export interface TokenRefreshResponse { + access: string; + refresh?: string; // Optional - only returned if refresh token rotation is enabled +} + +export interface LogoutResponse { + message: string; +} + +export interface CurrentUserResponse { + id: number; + username: string; + email: string; + display_name: string; + is_active: boolean; + date_joined: string; +} + +export interface PasswordResetRequest { + email: string; +} + +export interface PasswordResetResponse { + detail: string; +} + +export interface PasswordChangeRequest { + old_password: string; + new_password: string; + new_password_confirm: string; +} + +export interface PasswordChangeResponse { + detail: string; +} + +export interface SocialProvider { + id: string; + name: string; + authUrl: string; +} + +export type SocialProvidersResponse = SocialProvider[]; + +export interface AuthStatusResponse { + authenticated: boolean; + user: CurrentUserResponse | null; +} + +export interface SocialLoginRequest { + access_token: string; +} + +export interface SocialConnectionsResponse { + google: { connected: boolean; email?: string }; + discord: { connected: boolean; username?: string }; +} + +export interface SocialConnectResponse { + success: boolean; + message: string; +} + +export interface SocialDisconnectResponse { + success: boolean; + message: string; +} + +// ============================================================================ +// Social Provider Management Types +// ============================================================================ + +export interface ConnectedProvider { + provider: string; + provider_name: string; + uid: string; + date_joined: string; + can_disconnect: boolean; + disconnect_reason: string | null; + extra_data: Record; +} + +export interface AvailableProvider { + id: string; + name: string; + auth_url: string; + connect_url: string; +} + +export interface SocialAuthStatus { + user_id: number; + username: string; + email: string; + has_password_auth: boolean; + connected_providers: ConnectedProvider[]; + total_auth_methods: number; + can_disconnect_any: boolean; + requires_password_setup: boolean; +} + +export interface ConnectProviderRequest { + provider: string; +} + +export interface ConnectProviderResponse { + success: boolean; + message: string; + provider: string; + auth_url: string; +} + +export interface DisconnectProviderResponse { + success: boolean; + message: string; + provider: string; + remaining_providers: string[]; + has_password_auth: boolean; + suggestions?: string[]; +} + +export interface SocialProviderListResponse { + available_providers: AvailableProvider[]; + count: number; +} + +export interface ConnectedProvidersListResponse { + connected_providers: ConnectedProvider[]; + count: number; + has_password_auth: boolean; + can_disconnect_any: boolean; +} + +export interface SocialProviderError { + error: string; + code?: string; + suggestions?: string[]; + provider?: string; +} + +// ============================================================================ +// User Account Management Types +// ============================================================================ + +export interface CompleteUserProfile { + user_id: string; + username: string; + email: string; + display_name: string; + is_active: boolean; + date_joined: string; + role: "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER"; + theme_preference: "light" | "dark"; + profile: { + profile_id: string; + display_name: string; + avatar: string | null; + avatar_url: string; // Full avatar URL (Cloudflare or default) + avatar_variants: { + thumbnail: string; // 64x64 + avatar: string; // 200x200 + large: string; // 400x400 + }; + pronouns: string; + bio: string; + twitter: string; + instagram: string; + youtube: string; + discord: string; + coaster_credits: number; + dark_ride_credits: number; + flat_ride_credits: number; + water_ride_credits: number; + }; +} + +export interface AccountUpdate { + display_name?: string; + email?: string; +} + +export interface ProfileUpdate { + display_name?: string; + pronouns?: string; + bio?: string; + twitter?: string; + instagram?: string; + youtube?: string; + discord?: string; +} + +export interface AvatarUpload { + avatar: File; // Image file (JPEG, PNG, WebP) +} + +// For backward compatibility with FormData uploads +export interface AvatarFormData { + avatar: File; // Image file (JPEG, PNG, WebP) +} + +export interface AvatarUploadResponse { + success: boolean; + message: string; + avatar_url: string; + avatar_variants: { + thumbnail: string; // 64x64 + avatar: string; // 200x200 + large: string; // 400x400 + }; +} + +export interface AvatarDeleteResponse { + success: boolean; + message: string; + avatar_url: string; // Default UI Avatars URL +} + +export interface UserPreferences { + theme_preference: "light" | "dark"; + email_notifications: boolean; + push_notifications: boolean; + privacy_level: "public" | "friends" | "private"; + show_email: boolean; + show_real_name: boolean; + show_statistics: boolean; + allow_friend_requests: boolean; + allow_messages: boolean; +} + +export interface ThemeUpdate { + theme_preference: "light" | "dark"; +} + +export interface NotificationSettings { + email_notifications: { + new_reviews: boolean; + review_replies: boolean; + friend_requests: boolean; + messages: boolean; + weekly_digest: boolean; + new_features: boolean; + security_alerts: boolean; + }; + push_notifications: { + new_reviews: boolean; + review_replies: boolean; + friend_requests: boolean; + messages: boolean; + }; + in_app_notifications: { + new_reviews: boolean; + review_replies: boolean; + friend_requests: boolean; + messages: boolean; + system_announcements: boolean; + }; +} + +export interface PrivacySettings { + profile_visibility: "public" | "friends" | "private"; + show_email: boolean; + show_real_name: boolean; + show_join_date: boolean; + show_statistics: boolean; + show_reviews: boolean; + show_photos: boolean; + show_top_lists: boolean; + allow_friend_requests: boolean; + allow_messages: boolean; + allow_profile_comments: boolean; + search_visibility: boolean; + activity_visibility: "public" | "friends" | "private"; +} + +export interface SecuritySettings { + two_factor_enabled: boolean; + login_notifications: boolean; + session_timeout: number; // days + require_password_change: boolean; // read-only + last_password_change: string; // read-only + active_sessions: number; // read-only + login_history_retention: number; // days +} + +export interface SecuritySettingsUpdate { + two_factor_enabled?: boolean; + login_notifications?: boolean; + session_timeout?: number; // 5-180 days + login_history_retention?: number; // 30-365 days +} + +export interface UserStatistics { + ride_credits: { + coaster_credits: number; + dark_ride_credits: number; + flat_ride_credits: number; + water_ride_credits: number; + total_credits: number; + }; + contributions: { + park_reviews: number; + ride_reviews: number; + photos_uploaded: number; + top_lists_created: number; + helpful_votes_received: number; + }; + activity: { + days_active: number; + last_active: string; + average_review_rating: number; + most_reviewed_park: string; + favorite_ride_type: string; + }; + achievements: { + first_review: boolean; + photo_contributor: boolean; + top_reviewer: boolean; + park_explorer: boolean; + coaster_enthusiast: boolean; + }; +} + +export interface TopList { + id: number; + title: string; + category: "RC" | "DR" | "FR" | "WR" | "PK"; + description: string; + created_at: string; + updated_at: string; + items_count: number; +} + +export type TopListsResponse = TopList[]; + +export interface CreateTopList { + title: string; + category: "RC" | "DR" | "FR" | "WR" | "PK"; + description?: string; +} + +export interface NotificationResponse { count: number; next: string | null; previous: string | null; + results: UserNotification[]; + unread_count: number; } -export interface UserBasic { +export interface UserNotification { + id: number; + notification_type: "SUBMISSION" | "REVIEW" | "SOCIAL" | "SYSTEM" | "ACHIEVEMENT"; + title: string; + message: string; + is_read: boolean; + created_at: string; + read_at: string | null; + action_url: string | null; + related_object_type: string | null; + related_object_id: number | null; + metadata: Record; +} + +export interface MarkNotificationsRead { + notification_ids?: number[]; // Specific notification IDs to mark as read + mark_all?: boolean; // Mark all unread notifications as read +} + +export interface MarkReadResponse { + success: boolean; + marked_count: number; + message: string; +} + +export interface NotificationPreferences { + email_enabled: boolean; + push_enabled: boolean; + in_app_enabled: boolean; + submission_notifications: { + email: boolean; + push: boolean; + in_app: boolean; + }; + review_notifications: { + email: boolean; + push: boolean; + in_app: boolean; + }; + social_notifications: { + email: boolean; + push: boolean; + in_app: boolean; + }; + system_notifications: { + email: boolean; + push: boolean; + in_app: boolean; + }; + achievement_notifications: { + email: boolean; + push: boolean; + in_app: boolean; + }; +} + +export interface DeletionRequest { + success: boolean; + message: string; + expires_at: string; + email: string; +} + +export interface VerifyDeletion { + verification_code: string; // 8-character code from email +} + +export interface DeletionComplete { + success: boolean; + message: string; + deleted_user: { + username: string; + user_id: string; + email: string; + date_joined: string; + }; + preserved_submissions: { + park_reviews: number; + ride_reviews: number; + uploaded_park_photos: number; + uploaded_ride_photos: number; + top_lists: number; + edit_submissions: number; + photo_submissions: number; + }; + deletion_request: { + verification_code: string; + created_at: string; + verified_at: string; + }; +} + +export interface CancelDeletion { + success: boolean; + message: string; + had_pending_request: boolean; +} + +export interface DeletionEligibility { + can_delete: boolean; + reason: string | null; + user_info: { + username: string; + user_id: string; + email: string; + date_joined: string; + role: string; + }; + submissions_to_preserve: { + park_reviews: number; + ride_reviews: number; + uploaded_park_photos: number; + uploaded_ride_photos: number; + top_lists: number; + edit_submissions: number; + photo_submissions: number; + }; + total_submissions: number; +} + +// ============================================================================ +// Queue Routing Response Types +// ============================================================================ + +export interface AutoApprovedResponse { + success: true; + status: "auto_approved"; + message: string; + data: ParkDetail | RideDetail; // The created/updated entity + submission: { + id: number; + status: "APPROVED"; + approved_at: string; + approved_by: { + id: number; + username: string; + display_name: string; + }; + }; +} + +export interface QueuedResponse { + success: true; + status: "queued"; + message: string; + submission: { + id: number; + status: "PENDING"; + created_at: string; + queue_item: { + id: number; + title: string; + description: string; + priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW"; + estimated_review_time: string; + }; + }; +} + +export interface FailedResponse { + success: false; + status: "failed"; + message: string; + errors: Record; +} + +export type QueueRoutingResponse = AutoApprovedResponse | QueuedResponse | FailedResponse; + +export interface EditSubmission { + id: number; + title: string; + description: string; + status: "PENDING" | "APPROVED" | "REJECTED" | "ESCALATED"; + user: { + id: number; + username: string; + display_name: string; + }; + created_at: string; + reviewed_at: string | null; + reviewer_notes: string | null; + queue_item?: { + id: number; + title: string; + description: string; + priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW"; + estimated_review_time: string; + }; +} + +export interface PhotoSubmission { + id: number; + title: string; + description: string; + status: "PENDING" | "APPROVED" | "REJECTED"; + user: { + id: number; + username: string; + display_name: string; + }; + created_at: string; + reviewed_at: string | null; + reviewer_notes: string | null; + queue_item?: { + id: number; + title: string; + description: string; + priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW"; + estimated_review_time: string; + }; +} + +export interface QueueConfiguration { + default_priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW"; + estimated_review_times: { + URGENT: string; // "2-4 hours" + HIGH: string; // "4-8 hours" + MEDIUM: string; // "1-2 days" + LOW: string; // "2-5 days" + }; + auto_escalation: { + enabled: boolean; + escalate_after_hours: number; + }; +} + +// ============================================================================ +// Parks API Types +// ============================================================================ + +export interface ParkListResponse { + count: number; + next: string | null; + previous: string | null; + results: ParkSummary[]; +} + +export interface ParkSummary { + id: number; + name: string; + slug: string; + description: string; + location: { + country: string; + state: string; + city: string; + address: string; + latitude: number; + longitude: number; + }; + operator: { + id: number; + name: string; + slug: string; + } | null; + property_owner: { + id: number; + name: string; + slug: string; + } | null; + opening_date: string | null; + closing_date: string | null; + status: "OPERATING" | "CLOSED_TEMP" | "CLOSED_PERM" | "UNDER_CONSTRUCTION"; + ride_count: number; + average_rating: number | null; + banner_image: string | null; + card_image: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateParkRequest { + name: string; + description?: string; + operator_id?: number; + property_owner_id?: number; + opening_date?: string; // YYYY-MM-DD format + closing_date?: string; // YYYY-MM-DD format + status?: "OPERATING" | "CLOSED_TEMP" | "CLOSED_PERM" | "UNDER_CONSTRUCTION"; + location: { + country: string; + state: string; + city: string; + address?: string; + latitude?: number; + longitude?: number; + }; +} + +export interface ParkDetail extends ParkSummary { + rides: RideSummary[]; + photos: ParkPhoto[]; + reviews_count: number; + recent_reviews: ReviewSummary[]; + statistics: { + total_rides: number; + roller_coasters: number; + dark_rides: number; + flat_rides: number; + water_rides: number; + transport_rides: number; + }; +} + +export interface ParkPhoto { + id: number; + image_url: string; + image_variants: { + thumbnail: string; + medium: string; + large: string; + }; + caption: string; + photo_type: "GENERAL" | "ENTRANCE" | "RIDE" | "FOOD" | "SHOP" | "SHOW"; + uploaded_by: { + id: number; + username: string; + display_name: string; + }; + uploaded_at: string; +} + +export interface ParkFilterOptions { + park_types: Array<{value: string; label: string}>; + countries: string[]; + states: string[]; + ordering_options: Array<{value: string; label: string}>; +} + +export interface ParkImageSettings { + banner_image?: number; // Photo ID + card_image?: number; // Photo ID +} + +export interface ParkPhotosResponse { + count: number; + next: string | null; + previous: string | null; + results: ParkPhoto[]; +} + +export interface UploadParkPhoto { + image: File; + caption?: string; + photo_type?: "GENERAL" | "ENTRANCE" | "RIDE" | "FOOD" | "SHOP" | "SHOW"; +} + +export interface UpdateParkPhoto { + caption?: string; + photo_type?: "GENERAL" | "ENTRANCE" | "RIDE" | "FOOD" | "SHOP" | "SHOW"; +} + +// ============================================================================ +// Rides API Types +// ============================================================================ + +export interface RideListResponse { + count: number; + next: string | null; + previous: string | null; + results: RideSummary[]; +} + +export interface RideSummary { + id: number; + name: string; + slug: string; + description: string; + category: "RC" | "DR" | "FR" | "WR" | "TR" | "OT"; + status: "OPERATING" | "CLOSED_TEMP" | "SBNO" | "CLOSING" | "CLOSED_PERM" | "UNDER_CONSTRUCTION" | "DEMOLISHED" | "RELOCATED"; + park: { + id: number; + name: string; + slug: string; + }; + manufacturer: { + id: number; + name: string; + slug: string; + } | null; + designer: { + id: number; + name: string; + slug: string; + } | null; + ride_model: { + id: number; + name: string; + slug: string; + manufacturer: { + id: number; + name: string; + slug: string; + }; + } | null; + opening_date: string | null; + closing_date: string | null; + status_since: string | null; + min_height_in: number | null; + max_height_in: number | null; + capacity_per_hour: number | null; + ride_duration_seconds: number | null; + average_rating: number | null; + reviews_count: number; + banner_image: string | null; + card_image: string | null; + coaster_stats: { + roller_coaster_type: string; + track_material: string; + launch_type: string; + height_ft: number | null; + speed_mph: number | null; + inversions: number; + length_ft: number | null; + duration_seconds: number | null; + } | null; + created_at: string; + updated_at: string; +} + +export interface CreateRideRequest { + name: string; + description?: string; + category: "RC" | "DR" | "FR" | "WR" | "TR" | "OT"; + status?: "OPERATING" | "CLOSED_TEMP" | "SBNO" | "CLOSING" | "CLOSED_PERM" | "UNDER_CONSTRUCTION" | "DEMOLISHED" | "RELOCATED"; + park_id: number; + park_area_id?: number; + manufacturer_id?: number; + designer_id?: number; + ride_model_id?: number; + opening_date?: string; // YYYY-MM-DD format + closing_date?: string; // YYYY-MM-DD format + status_since?: string; // YYYY-MM-DD format + min_height_in?: number; + max_height_in?: number; + capacity_per_hour?: number; + ride_duration_seconds?: number; +} + +export interface RideDetail extends RideSummary { + photos: RidePhoto[]; + reviews_count: number; + recent_reviews: ReviewSummary[]; + location: { + latitude: number; + longitude: number; + } | null; + park_area: { + id: number; + name: string; + } | null; + ride_types: Array<{ + id: number; + name: string; + category: string; + }>; +} + +export interface RidePhoto { + id: number; + image_url: string; + image_variants: { + thumbnail: string; + medium: string; + large: string; + }; + caption: string; + photo_type: "GENERAL" | "STATION" | "LIFT" | "ELEMENT" | "TRAIN" | "QUEUE"; + uploaded_by: { + id: number; + username: string; + display_name: string; + }; + uploaded_at: string; +} + +export interface RideFilterOptions { + categories: Array<{value: string; label: string}>; + statuses: Array<{value: string; label: string}>; + post_closing_statuses: Array<{value: string; label: string}>; + roller_coaster_types: Array<{value: string; label: string}>; + track_materials: Array<{value: string; label: string}>; + launch_types: Array<{value: string; label: string}>; + ordering_options: Array<{value: string; label: string}>; + filter_ranges: { + rating: {min: number; max: number; step: number}; + height_requirement: {min: number; max: number; step: number; unit: string}; + capacity: {min: number; max: number; step: number; unit: string}; + height_ft: {min: number; max: number; step: number; unit: string}; + speed_mph: {min: number; max: number; step: number; unit: string}; + inversions: {min: number; max: number; step: number; unit: string}; + opening_year: {min: number; max: number; step: number; unit: string}; + }; + boolean_filters: Array<{ + key: string; + label: string; + description: string; + }>; +} + +export interface RideImageSettings { + banner_image?: number; // Photo ID + card_image?: number; // Photo ID +} + +export interface RidePhotosResponse { + count: number; + next: string | null; + previous: string | null; + results: RidePhoto[]; +} + +export interface UploadRidePhoto { + image: File; + caption?: string; + photo_type?: "GENERAL" | "STATION" | "LIFT" | "ELEMENT" | "TRAIN" | "QUEUE"; +} + +export interface UpdateRidePhoto { + caption?: string; + photo_type?: "GENERAL" | "STATION" | "LIFT" | "ELEMENT" | "TRAIN" | "QUEUE"; +} + +export interface ManufacturerRideModels { + manufacturer: { + id: number; + name: string; + slug: string; + description: string; + founded_year: number | null; + headquarters: string; + }; + ride_models: Array<{ + id: number; + name: string; + slug: string; + category: string; + description: string; + first_installation: string | null; + installations_count: number; + }>; +} + +// ============================================================================ +// Search & Core API Types +// ============================================================================ + +export interface CompanySearchResult { + id: number; + name: string; + slug: string; +} + +export type CompanySearchResponse = CompanySearchResult[]; + +export interface RideModelSearchResult { + id: number; + name: string; + category: string; +} + +export type RideModelSearchResponse = RideModelSearchResult[]; + +export interface SearchSuggestion { + suggestion: string; +} + +export type SearchSuggestionsResponse = SearchSuggestion[]; + +export interface EntitySearchResponse { + results: Array<{ + id: number; + name: string; + slug: string; + entity_type: "park" | "ride" | "company"; + description: string; + location?: string; + park_name?: string; // For rides + score: number; // Relevance score + }>; + total_count: number; +} + +export interface EntityNotFoundRequest { + search_query: string; + entity_type?: "park" | "ride" | "company"; + additional_info?: string; +} + +export interface EntityNotFoundResponse { + success: boolean; + message: string; +} + +export interface EntitySuggestion { + id: number; + name: string; + entity_type: "park" | "ride" | "company"; + subtitle?: string; // Additional context (e.g., park name for rides) +} + +export type EntitySuggestionsResponse = EntitySuggestion[]; + +// ============================================================================ +// Maps API Types +// ============================================================================ + +export interface MapLocationsResponse { + locations: Array<{ + id: number; + name: string; + entity_type: "park" | "ride"; + latitude: number; + longitude: number; + category?: string; // For rides + park_name?: string; // For rides + status: string; + marker_color: string; + }>; + clusters: Array<{ + latitude: number; + longitude: number; + count: number; + bounds: { + sw_lat: number; + sw_lng: number; + ne_lat: number; + ne_lng: number; + }; + }>; +} + +export interface LocationDetail { + id: number; + name: string; + entity_type: "park" | "ride"; + latitude: number; + longitude: number; + address: string; + description: string; + status: string; + images: string[]; + park_name?: string; // For rides + category?: string; // For rides + opening_date?: string; + website_url?: string; +} + +export interface MapSearchResponse { + results: Array<{ + id: number; + name: string; + entity_type: "park" | "ride"; + latitude: number; + longitude: number; + relevance_score: number; + }>; +} + +export interface MapStatsResponse { + total_parks: number; + total_rides: number; + parks_with_coordinates: number; + rides_with_coordinates: number; + coverage_percentage: number; + last_updated: string; +} + +export interface MapCacheResponse { + cache_size: number; + cache_hits: number; + cache_misses: number; + last_cleared: string; +} + +export interface CacheInvalidateResponse { + success: boolean; + message: string; +} + +// ============================================================================ +// Health & Statistics Types +// ============================================================================ + +export interface HealthCheckResponse { + status: "healthy" | "degraded" | "unhealthy"; + timestamp: string; + version: string; + checks: { + database: { + status: "healthy" | "unhealthy"; + response_time_ms: number; + }; + cache: { + status: "healthy" | "unhealthy"; + response_time_ms: number; + }; + storage: { + status: "healthy" | "unhealthy"; + response_time_ms: number; + }; + }; +} + +export interface SimpleHealthResponse { + status: "ok"; + timestamp: string; +} + +export interface PerformanceMetricsResponse { + cpu_usage: number; + memory_usage: number; + disk_usage: number; + active_connections: number; + response_times: { + avg_ms: number; + p95_ms: number; + p99_ms: number; + }; + error_rates: { + last_hour: number; + last_day: number; + }; +} + +export interface SystemStatsResponse { + parks: { + total: number; + operating: number; + closed: number; + under_construction: number; + }; + rides: { + total: number; + by_category: { + roller_coasters: number; + dark_rides: number; + flat_rides: number; + water_rides: number; + transport: number; + other: number; + }; + by_status: { + operating: number; + closed_temp: number; + closed_perm: number; + under_construction: number; + }; + }; + users: { + total: number; + active_last_30_days: number; + new_this_month: number; + }; + content: { + reviews: number; + photos: number; + top_lists: number; + }; + last_updated: string; +} + +export interface StatsRecalculateResponse { + success: boolean; + message: string; + started_at: string; +} + +// ============================================================================ +// Trending & Discovery Types +// ============================================================================ + +export interface TrendingResponse { + trending_parks: Array<{ + id: number; + name: string; + slug: string; + trend_score: number; + trend_change: number; // Percentage change + recent_activity: string; + }>; + trending_rides: Array<{ + id: number; + name: string; + slug: string; + park_name: string; + trend_score: number; + trend_change: number; + recent_activity: string; + }>; +} + +export interface NewContentResponse { + new_parks: Array<{ + id: number; + name: string; + slug: string; + added_date: string; + opening_date: string | null; + }>; + new_rides: Array<{ + id: number; + name: string; + slug: string; + park_name: string; + category: string; + added_date: string; + opening_date: string | null; + }>; +} + +export interface TriggerTrendingResponse { + success: boolean; + message: string; + calculation_started: string; +} + +// ============================================================================ +// Reviews & Rankings Types +// ============================================================================ + +export interface LatestReviewsResponse { + reviews: ReviewSummary[]; +} + +export interface ReviewSummary { + id: number; + rating: number; + title: string; + content: string; + entity_type: "park" | "ride"; + entity_id: number; + entity_name: string; + park_name?: string; // For ride reviews + author: { + id: number; + username: string; + display_name: string; + avatar_url: string; + }; + created_at: string; + helpful_votes: number; + total_votes: number; +} + +export interface RankingsResponse { + count: number; + next: string | null; + previous: string | null; + results: RankedRide[]; + metadata: { + total_rides: number; + average_rating: number; + total_reviews: number; + last_updated: string; + }; +} + +export interface RankedRide { + rank: number; + id: number; + name: string; + slug: string; + category: string; + park: { + id: number; + name: string; + slug: string; + }; + manufacturer: { + id: number; + name: string; + slug: string; + } | null; + average_rating: number; + reviews_count: number; + rating_distribution: { + "1": number; + "2": number; + "3": number; + "4": number; + "5": number; + "6": number; + "7": number; + "8": number; + "9": number; + "10": number; + }; + coaster_stats: { + height_ft: number | null; + speed_mph: number | null; + inversions: number; + roller_coaster_type: string; + track_material: string; + } | null; + banner_image: string | null; + card_image: string | null; +} + +export interface RideRankingDetail { + ride: RankedRide; + global_rank: number; + category_rank: number; + park_rank: number; + manufacturer_rank: number | null; + similar_rides: Array<{ + id: number; + name: string; + park_name: string; + average_rating: number; + rank_difference: number; // Positive if ranked higher, negative if lower + }>; + ranking_history: Array<{ + date: string; + global_rank: number; + category_rank: number; + average_rating: number; + reviews_count: number; + }>; +} + +export interface RankingCalculationResponse { + success: boolean; + message: string; + calculation_started: string; + estimated_completion: string; +} + +// ============================================================================ +// Email Service Types +// ============================================================================ + +export interface ContactEmailRequest { + name: string; + email: string; + subject: string; + message: string; + category?: "GENERAL" | "BUG_REPORT" | "FEATURE_REQUEST" | "CONTENT_ISSUE" | "ACCOUNT_ISSUE"; +} + +export interface ContactEmailResponse { + success: boolean; + message: string; + reference_id: string; +} + +export interface NewsletterSubscribeRequest { + email: string; + preferences?: { + weekly_digest: boolean; + new_features: boolean; + community_highlights: boolean; + }; +} + +export interface NewsletterSubscribeResponse { + success: boolean; + message: string; + subscription_id: string; +} + +export interface NewsletterUnsubscribeRequest { + email: string; + token?: string; // Unsubscribe token from email +} + +export interface NewsletterUnsubscribeResponse { + success: boolean; + message: string; +} + +// ============================================================================ +// History API Types +// ============================================================================ + +export interface EntityHistoryResponse { + count: number; + next: string | null; + previous: string | null; + results: HistoryEntry[]; +} + +export interface HistoryEntry { + id: number; + timestamp: string; + user: { + id: number; + username: string; + display_name: string; + } | null; + action: "CREATE" | "UPDATE" | "DELETE"; + changes: Array<{ + field: string; + old_value: any; + new_value: any; + }>; + reason: string | null; + ip_address: string; +} + +export interface RecentChangesResponse { + changes: Array<{ + id: number; + entity_type: "park" | "ride"; + entity_id: number; + entity_name: string; + action: "CREATE" | "UPDATE" | "DELETE"; + timestamp: string; + user: { + id: number; + username: string; + display_name: string; + } | null; + summary: string; // Human-readable summary of changes + }>; + total_count: number; +} + +// ============================================================================ +// Error Response Types +// ============================================================================ + +export interface ApiError { + status: "error"; + error: { + code: string; + message: string; + details?: any; + request_user?: string; + }; + data: null; +} + +export interface EnhancedApiError { + status: "error"; + error: { + code: string; + message: string; + error_code: string; + user_info?: { + username: string; + role: string; + is_superuser: boolean; + is_staff: boolean; + }; + help_text?: string; + details?: any; + request_user?: string; + }; + data: null; +} + +export interface ValidationError { + [field: string]: string[]; +} + +export interface AuthError { + detail: string; +} + +export interface PermissionError { + detail: string; +} + +export interface NotFoundError { + detail: string; +} + +export interface ServerError { + detail: string; +} + +// ============================================================================ +// Common Utility Types +// ============================================================================ + +export type RideCategory = "RC" | "DR" | "FR" | "WR" | "TR" | "OT"; +export type RideStatus = "OPERATING" | "CLOSED_TEMP" | "SBNO" | "CLOSING" | "CLOSED_PERM" | "UNDER_CONSTRUCTION" | "DEMOLISHED" | "RELOCATED"; +export type ParkStatus = "OPERATING" | "CLOSED_TEMP" | "CLOSED_PERM" | "UNDER_CONSTRUCTION"; +export type UserRole = "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER"; +export type ThemePreference = "light" | "dark"; +export type PrivacyLevel = "public" | "friends" | "private"; +export type NotificationType = "SUBMISSION" | "REVIEW" | "SOCIAL" | "SYSTEM" | "ACHIEVEMENT"; +export type EntityType = "park" | "ride" | "company"; +export type PhotoType = "GENERAL" | "ENTRANCE" | "RIDE" | "FOOD" | "SHOP" | "SHOW" | "STATION" | "LIFT" | "ELEMENT" | "TRAIN" | "QUEUE"; +export type HealthStatus = "healthy" | "degraded" | "unhealthy"; +export type ActionType = "CREATE" | "UPDATE" | "DELETE"; + +// ============================================================================ +// API Response Wrapper Types +// ============================================================================ + +export interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + errors?: ValidationError; +} + +// ============================================================================ +// Form Data Types for File Uploads +// ============================================================================ + +export interface FileUploadData { + file: File; + caption?: string; + photo_type?: PhotoType; +} + +// ============================================================================ +// Filter and Search Types +// ============================================================================ + +export interface FilterOption { + value: string; + label: string; +} + +export interface FilterRange { + min: number; + max: number; + step: number; + unit?: string; +} + +export interface BooleanFilter { + key: string; + label: string; + description: string; +} + +export interface SearchFilters { + search?: string; + categories?: RideCategory[]; + statuses?: RideStatus[]; + park_id?: number; + park_slug?: string; + manufacturer_id?: number; + manufacturer_slug?: string; + designer_id?: number; + designer_slug?: string; + min_rating?: number; + max_rating?: number; + min_height_requirement?: number; + max_height_requirement?: number; + min_capacity?: number; + max_capacity?: number; + min_height_ft?: number; + max_height_ft?: number; + min_speed_mph?: number; + max_speed_mph?: number; + min_inversions?: number; + max_inversions?: number; + has_inversions?: boolean; + opening_year?: number; + min_opening_year?: number; + max_opening_year?: number; + ordering?: string; + page?: number; + page_size?: number; +} + +// ============================================================================ +// Location and Geographic Types +// ============================================================================ + +export interface Coordinates { + latitude: number; + longitude: number; +} + +export interface BoundingBox { + sw_lat: number; + sw_lng: number; + ne_lat: number; + ne_lng: number; +} + +export interface LocationData { + country: string; + state: string; + city: string; + address?: string; + latitude?: number; + longitude?: number; +} + +// ============================================================================ +// Statistics and Analytics Types +// ============================================================================ + +export interface RatingDistribution { + "1": number; + "2": number; + "3": number; + "4": number; + "5": number; + "6": number; + "7": number; + "8": number; + "9": number; + "10": number; +} + +export interface CoasterStats { + roller_coaster_type: string; + track_material: string; + launch_type: string; + height_ft: number | null; + speed_mph: number | null; + inversions: number; + length_ft: number | null; + duration_seconds: number | null; +} + +export interface ParkStatistics { + total_rides: number; + roller_coasters: number; + dark_rides: number; + flat_rides: number; + water_rides: number; + transport_rides: number; +} + +// ============================================================================ +// Company and Manufacturer Types +// ============================================================================ + +export interface CompanyInfo { + id: number; + name: string; + slug: string; + description?: string; + founded_year?: number | null; + headquarters?: string; +} + +export interface RideModelInfo { + id: number; + name: string; + slug: string; + category: string; + description?: string; + first_installation?: string | null; + installations_count?: number; + manufacturer: CompanyInfo; +} + +// ============================================================================ +// User and Profile Types +// ============================================================================ + +export interface UserInfo { id: number; username: string; display_name: string; - email: string; - role: "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER"; + avatar_url?: string; } -export interface ContentType { - id: number; - app_label: string; - model: string; +export interface ProfileStatistics { + coaster_credits: number; + dark_ride_credits: number; + flat_ride_credits: number; + water_ride_credits: number; + total_credits: number; +} + +export interface UserAchievements { + first_review: boolean; + photo_contributor: boolean; + top_reviewer: boolean; + park_explorer: boolean; + coaster_enthusiast: boolean; } // ============================================================================ -// Moderation System Types +// Content Moderation Types // ============================================================================ -// Moderation Report Types export interface ModerationReport { id: number; report_type: "SPAM" | "HARASSMENT" | "INAPPROPRIATE_CONTENT" | "MISINFORMATION" | "COPYRIGHT" | "PRIVACY" | "HATE_SPEECH" | "VIOLENCE" | "OTHER"; @@ -53,42 +1734,63 @@ export interface ModerationReport { status_display: string; priority: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; priority_display: string; - reported_entity_type: string; + reported_entity_type: "park" | "ride" | "review" | "photo" | "user" | "comment"; reported_entity_id: number; reason: string; description: string; evidence_urls: string[]; - resolved_at: string | null; - resolution_notes: string; + reported_by: { + id: number; + username: string; + display_name: string; + email: string; + role: UserRole; + }; + assigned_moderator: { + id: number; + username: string; + display_name: string; + email: string; + role: UserRole; + } | null; + content_type: { + id: number; + app_label: string; + model: string; + } | null; resolution_action: string; + resolution_notes: string; + resolved_at: string | null; created_at: string; updated_at: string; - reported_by: UserBasic; - assigned_moderator: UserBasic | null; - content_type: ContentType | null; + // Computed fields is_overdue: boolean; time_since_created: string; } -export interface CreateModerationReportData { - report_type: ModerationReport["report_type"]; - reported_entity_type: string; +export interface ModerationReportsResponse { + count: number; + next: string | null; + previous: string | null; + results: ModerationReport[]; + stats: { + pending: number; + under_review: number; + resolved_today: number; + total_open: number; + }; +} + +export interface CreateModerationReport { + report_type: "INAPPROPRIATE_CONTENT" | "SPAM" | "HARASSMENT" | "COPYRIGHT" | "MISINFORMATION" | "OTHER"; + reported_entity_type: "park" | "ride" | "review" | "photo" | "user" | "comment"; reported_entity_id: number; reason: string; description: string; evidence_urls?: string[]; } -export interface UpdateModerationReportData { - status?: ModerationReport["status"]; - priority?: ModerationReport["priority"]; - assigned_moderator?: number; - resolution_notes?: string; - resolution_action?: string; -} - -// Moderation Queue Types -export interface ModerationQueue { +export interface ModerationQueueItem { id: number; item_type: "CONTENT_REVIEW" | "USER_REVIEW" | "BULK_ACTION" | "POLICY_VIOLATION" | "APPEAL" | "OTHER"; status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED"; @@ -98,26 +1800,63 @@ export interface ModerationQueue { entity_type: string; entity_id: number | null; entity_preview: Record; - flagged_by: UserBasic | null; + assigned_to: { + id: number; + username: string; + display_name: string; + email: string; + role: UserRole; + } | null; assigned_at: string | null; - estimated_review_time: number; + estimated_review_time: number; // minutes + flagged_by: { + id: number; + username: string; + display_name: string; + } | null; + tags: string[]; + content_type: { + id: number; + app_label: string; + model: string; + } | null; + related_report: ModerationReport | null; created_at: string; updated_at: string; - tags: string[]; - assigned_to: UserBasic | null; - related_report: ModerationReport | null; - content_type: ContentType | null; + // Computed fields is_overdue: boolean; - time_in_queue: number; + time_in_queue: number; // minutes estimated_completion: string; } -export interface CompleteQueueItemData { - action: "NO_ACTION" | "CONTENT_REMOVED" | "CONTENT_EDITED" | "USER_WARNING" | "USER_SUSPENDED" | "USER_BANNED"; - notes?: string; +export interface ModerationQueueResponse { + count: number; + next: string | null; + previous: string | null; + results: ModerationQueueItem[]; + queue_stats: { + total_pending: number; + total_in_progress: number; + average_resolution_time: number; // minutes + oldest_pending_age: number; // hours + my_assigned: number; + }; +} + +// Unified moderation report update interface +export interface UpdateModerationReportData { + status?: "PENDING" | "UNDER_REVIEW" | "RESOLVED" | "DISMISSED"; + priority?: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; + assigned_moderator?: number | null; + resolution_notes?: string; + resolution_action?: string; +} + +// Backward compatibility alias +export interface UpdateModerationReport extends UpdateModerationReportData { + assigned_moderator_id?: number | null; // Legacy field name } -// Moderation Action Types export interface ModerationAction { id: number; action_type: "WARNING" | "USER_SUSPENSION" | "USER_BAN" | "CONTENT_REMOVAL" | "CONTENT_EDIT" | "CONTENT_RESTRICTION" | "ACCOUNT_RESTRICTION" | "OTHER"; @@ -126,18 +1865,308 @@ export interface ModerationAction { details: string; duration_hours: number | null; created_at: string; + updated_at: string; expires_at: string | null; is_active: boolean; - moderator: UserBasic; - target_user: UserBasic; + moderator: { + id: number; + username: string; + display_name: string; + email: string; + role: UserRole; + }; + target_user: { + id: number; + username: string; + display_name: string; + email: string; + role: UserRole; + }; related_report: ModerationReport | null; - updated_at: string; + // Computed fields is_expired: boolean; time_remaining: string | null; } +// ============================================================================ +// User Moderation Types +// ============================================================================ + +export interface UserModerationProfile { + user: { + id: number; + username: string; + display_name: string; + email: string; + date_joined: string; + last_login: string | null; + is_active: boolean; + role: "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER"; + }; + moderation_history: { + total_reports_against: number; + total_warnings: number; + total_suspensions: number; + total_bans: number; + current_restrictions: ModerationAction[]; + recent_actions: ModerationAction[]; + }; + account_stats: { + total_reviews: number; + total_photos: number; + total_reports_made: number; + helpful_votes_received: number; + account_age_days: number; + last_activity: string; + }; + risk_assessment: { + risk_level: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + risk_factors: string[]; + trust_score: number; // 0-100 + automated_flags: number; + }; +} + +export interface UserModerationAction { + action_type: "WARNING" | "SUSPEND" | "BAN" | "RESTRICT_POSTING" | "RESTRICT_PHOTOS" | "REMOVE_CONTENT"; + reason: string; + details: string; + duration_hours?: number; // Required for temporary actions + notify_user: boolean; + remove_content?: boolean; // For content-related actions + content_types?: ("reviews" | "photos" | "comments")[]; // Specific content to remove +} + +export interface UserModerationActionResponse { + success: boolean; + message: string; + action: ModerationAction; + affected_content?: { + reviews_removed: number; + photos_removed: number; + comments_removed: number; + }; +} + +export interface UserModerationStats { + total_users: number; + active_users_30d: number; + users_with_restrictions: number; + users_banned: number; + users_suspended: number; + pending_appeals: number; + moderation_actions_today: number; + average_response_time_hours: number; +} + +// ============================================================================ +// Bulk Operations Types +// ============================================================================ + +export interface BulkOperation { + id: string; + operation_type: "UPDATE_PARKS" | "UPDATE_RIDES" | "IMPORT_DATA" | "EXPORT_DATA" | "RECALCULATE_STATS" | "MODERATE_CONTENT" | "USER_ACTIONS"; + status: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED"; + created_by: { + id: number; + username: string; + display_name: string; + }; + created_at: string; + started_at: string | null; + completed_at: string | null; + progress: { + total_items: number; + processed_items: number; + failed_items: number; + percentage: number; + }; + parameters: Record; + results: { + success_count: number; + error_count: number; + warnings: string[]; + errors: string[]; + summary: string; + } | null; + estimated_duration_minutes: number | null; + can_cancel: boolean; +} + +export interface BulkOperationsResponse { + count: number; + next: string | null; + previous: string | null; + results: BulkOperation[]; + active_operations: number; + queued_operations: number; +} + +export interface CreateBulkOperation { + operation_type: "UPDATE_PARKS" | "UPDATE_RIDES" | "IMPORT_DATA" | "EXPORT_DATA" | "RECALCULATE_STATS" | "MODERATE_CONTENT" | "USER_ACTIONS"; + parameters: Record; + description?: string; + priority?: "LOW" | "NORMAL" | "HIGH"; + schedule_for?: string; // ISO datetime for scheduled operations +} + +export interface BulkUpdateParks { + operation_type: "UPDATE_PARKS"; + parameters: { + park_ids?: number[]; + filters?: { + country?: string; + state?: string; + status?: ParkStatus; + operator_id?: number; + }; + updates: { + status?: ParkStatus; + operator_id?: number; + property_owner_id?: number; + description?: string; + custom_fields?: Record; + }; + batch_size?: number; + }; +} + +export interface BulkUpdateRides { + operation_type: "UPDATE_RIDES"; + parameters: { + ride_ids?: number[]; + filters?: { + park_id?: number; + category?: RideCategory; + status?: RideStatus; + manufacturer_id?: number; + }; + updates: { + status?: RideStatus; + manufacturer_id?: number; + designer_id?: number; + ride_model_id?: number; + capacity_per_hour?: number; + custom_fields?: Record; + }; + batch_size?: number; + }; +} + +export interface BulkImportData { + operation_type: "IMPORT_DATA"; + parameters: { + data_type: "PARKS" | "RIDES" | "COMPANIES" | "REVIEWS"; + source_format: "CSV" | "JSON" | "XML"; + file_url: string; + mapping_config: Record; + validation_rules?: Record; + conflict_resolution: "SKIP" | "UPDATE" | "CREATE_NEW"; + batch_size?: number; + }; +} + +export interface BulkExportData { + operation_type: "EXPORT_DATA"; + parameters: { + data_type: "PARKS" | "RIDES" | "COMPANIES" | "REVIEWS" | "USERS"; + format: "CSV" | "JSON" | "XML"; + filters?: Record; + include_fields?: string[]; + exclude_fields?: string[]; + include_relationships?: boolean; + }; +} + +export interface BulkModerateContent { + operation_type: "MODERATE_CONTENT"; + parameters: { + content_type: "reviews" | "photos" | "comments"; + action: "APPROVE" | "REJECT" | "FLAG" | "REMOVE"; + filters?: { + reported_by_users?: number[]; + content_ids?: number[]; + date_range?: { + start: string; + end: string; + }; + keywords?: string[]; + }; + reason?: string; + notify_users?: boolean; + }; +} + +export interface BulkUserActions { + operation_type: "USER_ACTIONS"; + parameters: { + user_ids?: number[]; + filters?: { + role?: UserRole; + registration_date_range?: { + start: string; + end: string; + }; + last_activity_before?: string; + has_violations?: boolean; + }; + action: "SUSPEND" | "UNSUSPEND" | "BAN" | "UNBAN" | "SEND_EMAIL" | "UPDATE_ROLE"; + action_parameters?: { + duration_hours?: number; + reason?: string; + email_template?: string; + new_role?: UserRole; + }; + notify_users?: boolean; + }; +} + +export interface BulkOperationResult { + operation_id: string; + success: boolean; + message: string; + estimated_completion: string; +} + +// ============================================================================ +// Moderation Statistics and Dashboard Types +// ============================================================================ + +export interface ModerationStatsData { + // Report stats + total_reports: number; + pending_reports: number; + resolved_reports: number; + overdue_reports: number; + // Queue stats + queue_size: number; + assigned_items: number; + unassigned_items: number; + // Action stats + total_actions: number; + active_actions: number; + expired_actions: number; + // Bulk operation stats + running_operations: number; + completed_operations: number; + failed_operations: number; + // Performance metrics + average_resolution_time_hours: number; + reports_by_priority: Record; + reports_by_type: Record; +} + +export interface AssignQueueItemData { + moderator_id: number; +} + +export interface CompleteQueueItemData { + action: "NO_ACTION" | "CONTENT_REMOVED" | "CONTENT_EDITED" | "USER_WARNING" | "USER_SUSPENDED" | "USER_BANNED"; + notes?: string; +} + export interface CreateModerationActionData { - action_type: ModerationAction["action_type"]; + action_type: "WARNING" | "USER_SUSPENSION" | "USER_BAN" | "CONTENT_REMOVAL" | "CONTENT_EDIT" | "CONTENT_RESTRICTION" | "ACCOUNT_RESTRICTION" | "OTHER"; reason: string; details: string; duration_hours?: number; @@ -145,44 +2174,22 @@ export interface CreateModerationActionData { related_report_id?: number; } -// Bulk Operation Types -export interface BulkOperation { - id: string; - operation_type: "UPDATE_PARKS" | "UPDATE_RIDES" | "IMPORT_DATA" | "EXPORT_DATA" | "MODERATE_CONTENT" | "USER_ACTIONS" | "CLEANUP" | "OTHER"; - operation_type_display: string; - status: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED"; - status_display: string; - priority: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; - parameters: Record; - results: Record; - total_items: number; - processed_items: number; - failed_items: number; - created_at: string; - started_at: string | null; - completed_at: string | null; - estimated_duration_minutes: number | null; - can_cancel: boolean; - description: string; - schedule_for: string | null; - created_by: UserBasic; - updated_at: string; - progress_percentage: number; - estimated_completion: string | null; +export interface UpdateModerationReportData { + status?: "PENDING" | "UNDER_REVIEW" | "RESOLVED" | "DISMISSED"; + priority?: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; + assigned_moderator?: number | null; + resolution_notes?: string; + resolution_action?: string; } -export interface CreateBulkOperationData { - operation_type: BulkOperation["operation_type"]; - priority?: BulkOperation["priority"]; - parameters: Record; - description: string; - schedule_for?: string; - estimated_duration_minutes?: number; -} - -// User Moderation Profile Types -export interface UserModerationProfile { - user: UserBasic; +export interface UserModerationProfileData { + user: { + id: number; + username: string; + display_name: string; + email: string; + role: UserRole; + }; reports_made: number; reports_against: number; warnings_received: number; @@ -197,416 +2204,390 @@ export interface UserModerationProfile { next_review_date: string | null; } -// Moderation Statistics Types -export interface ModerationStatsData { - total_reports: number; - pending_reports: number; - resolved_reports: number; - overdue_reports: number; - queue_size: number; - assigned_items: number; - unassigned_items: number; - total_actions: number; - active_actions: number; - expired_actions: number; - running_operations: number; - completed_operations: number; - failed_operations: number; - average_resolution_time_hours: number; - reports_by_priority: Record; - reports_by_type: Record; -} - // ============================================================================ -// Parks API Types +// Moderation Filter Types // ============================================================================ -export interface Park { - id: number; - name: string; - slug: string; - description: string; - country: string; - state: string; - city: string; - address: string; - postal_code: string; - phone: string; - email: string; - website: string; - opened_date: string | null; - closed_date: string | null; - status: "OPERATING" | "CLOSED_TEMP" | "CLOSED_PERM" | "UNDER_CONSTRUCTION"; - park_type: "THEME_PARK" | "AMUSEMENT_PARK" | "WATER_PARK" | "FAMILY_ENTERTAINMENT_CENTER" | "OTHER"; - timezone: string; - latitude: number | null; - longitude: number | null; - ride_count: number; - operating_ride_count: number; - photo_count: number; - banner_image: string | null; - card_image: string | null; - created_at: string; - updated_at: string; -} - -export interface ParkPhoto { - id: number; - image: string; - caption: string; - photo_type: "banner" | "card" | "gallery"; - uploaded_by: UserBasic; - upload_date: string; - is_approved: boolean; - likes_count: number; - views_count: number; -} - -// ============================================================================ -// Rides API Types -// ============================================================================ - -export interface Ride { - id: number; - name: string; - slug: string; - park: Park; - description: string; - ride_type: string; - manufacturer: Manufacturer | null; - model: RideModel | null; - opened_date: string | null; - closed_date: string | null; - status: "OPERATING" | "CLOSED_TEMP" | "SBNO" | "UNDER_CONSTRUCTION" | "REMOVED"; - height_requirement: number | null; - max_height: number | null; - duration_seconds: number | null; - max_speed_mph: number | null; - max_height_ft: number | null; - length_ft: number | null; - inversions: number | null; - capacity_per_hour: number | null; - photo_count: number; - review_count: number; - average_rating: number | null; - banner_image: string | null; - card_image: string | null; - created_at: string; - updated_at: string; -} - -export interface Manufacturer { - id: number; - name: string; - slug: string; - description: string; - country: string; - founded_year: number | null; - website: string; - ride_count: number; - created_at: string; - updated_at: string; -} - -export interface RideModel { - id: number; - name: string; - slug: string; - manufacturer: Manufacturer; - description: string; - ride_type: string; - first_built_year: number | null; - ride_count: number; - created_at: string; - updated_at: string; -} - -export interface RidePhoto { - id: number; - image: string; - caption: string; - photo_type: "banner" | "card" | "gallery"; - uploaded_by: UserBasic; - upload_date: string; - is_approved: boolean; - likes_count: number; - views_count: number; -} - -export interface RideReview { - id: number; - ride: Ride; - user: UserBasic; - rating: number; - title: string; - content: string; - visit_date: string | null; - created_at: string; - updated_at: string; - likes_count: number; - is_liked: boolean; -} - -export interface CreateRideReviewData { - rating: number; - title: string; - content: string; - visit_date?: string; -} - -// ============================================================================ -// Authentication Types -// ============================================================================ - -export interface LoginData { - username: string; - password: string; -} - -export interface SignupData { - username: string; - email: string; - password: string; - first_name?: string; - last_name?: string; -} - -export interface AuthResponse { - access: string; - refresh: string; - user: UserProfile; -} - -export interface UserProfile { - id: number; - username: string; - email: string; - first_name: string; - last_name: string; - display_name: string; - role: "USER" | "MODERATOR" | "ADMIN" | "SUPERUSER"; - date_joined: string; - last_login: string | null; - is_active: boolean; - profile_image: string | null; - bio: string; - location: string; - website: string; - birth_date: string | null; - privacy_settings: Record; -} - -export interface PasswordResetData { - email: string; -} - -export interface PasswordChangeData { - old_password: string; - new_password: string; -} - -// ============================================================================ -// Statistics Types -// ============================================================================ - -export interface GlobalStats { - total_parks: number; - total_rides: number; - total_reviews: number; - total_photos: number; - total_users: number; - active_users_30d: number; - new_content_7d: number; - top_countries: Array<{ - country: string; - park_count: number; - ride_count: number; - }>; - recent_activity: Array<{ - type: string; - description: string; - timestamp: string; - }>; -} - -export interface TrendingContent { - parks: Park[]; - rides: Ride[]; - reviews: RideReview[]; - time_period: "24h" | "7d" | "30d"; - generated_at: string; -} - -// ============================================================================ -// Search and Filter Types -// ============================================================================ - -export interface SearchFilters { +export interface ModerationReportFilters { + status?: "PENDING" | "UNDER_REVIEW" | "RESOLVED" | "DISMISSED"; + priority?: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; + report_type?: "SPAM" | "HARASSMENT" | "INAPPROPRIATE_CONTENT" | "MISINFORMATION" | "COPYRIGHT" | "PRIVACY" | "HATE_SPEECH" | "VIOLENCE" | "OTHER"; + reported_by?: number; + assigned_moderator?: number; + created_after?: string; + created_before?: string; + resolved_after?: string; + resolved_before?: string; + content_type?: string; + unassigned?: boolean; + overdue?: boolean; + has_resolution?: boolean; search?: string; ordering?: string; page?: number; page_size?: number; } -export interface ParkFilters extends SearchFilters { - country?: string; - state?: string; - city?: string; - status?: Park["status"]; - park_type?: Park["park_type"]; - has_rides?: boolean; -} - -export interface RideFilters extends SearchFilters { - park?: string; - manufacturer?: string; - ride_type?: string; - status?: Ride["status"]; - opened_after?: string; - opened_before?: string; - height_min?: number; - height_max?: number; - has_photos?: boolean; -} - -export interface ModerationReportFilters extends SearchFilters { - status?: ModerationReport["status"]; - priority?: ModerationReport["priority"]; - report_type?: ModerationReport["report_type"]; - reported_by?: number; - assigned_moderator?: number; - created_after?: string; - created_before?: string; - unassigned?: boolean; - overdue?: boolean; - has_resolution?: boolean; -} - -export interface ModerationQueueFilters extends SearchFilters { - status?: ModerationQueue["status"]; - priority?: ModerationQueue["priority"]; - item_type?: ModerationQueue["item_type"]; +export interface ModerationQueueFilters { + status?: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED"; + priority?: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; + item_type?: "CONTENT_REVIEW" | "USER_REVIEW" | "BULK_ACTION" | "POLICY_VIOLATION" | "APPEAL" | "OTHER"; assigned_to?: number; unassigned?: boolean; + created_after?: string; + created_before?: string; + assigned_after?: string; + assigned_before?: string; + content_type?: string; has_related_report?: boolean; + search?: string; + ordering?: string; + page?: number; + page_size?: number; } -export interface ModerationActionFilters extends SearchFilters { - action_type?: ModerationAction["action_type"]; +export interface ModerationActionFilters { + action_type?: "WARNING" | "USER_SUSPENSION" | "USER_BAN" | "CONTENT_REMOVAL" | "CONTENT_EDIT" | "CONTENT_RESTRICTION" | "ACCOUNT_RESTRICTION" | "OTHER"; moderator?: number; target_user?: number; is_active?: boolean; + created_after?: string; + created_before?: string; + expires_after?: string; + expires_before?: string; expired?: boolean; expiring_soon?: boolean; has_related_report?: boolean; + search?: string; + ordering?: string; + page?: number; + page_size?: number; } -export interface BulkOperationFilters extends SearchFilters { - status?: BulkOperation["status"]; - operation_type?: BulkOperation["operation_type"]; - priority?: BulkOperation["priority"]; +export interface BulkOperationFilters { + status?: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED"; + operation_type?: "UPDATE_PARKS" | "UPDATE_RIDES" | "IMPORT_DATA" | "EXPORT_DATA" | "MODERATE_CONTENT" | "USER_ACTIONS" | "OTHER"; + priority?: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; created_by?: number; + created_after?: string; + created_before?: string; + started_after?: string; + started_before?: string; + completed_after?: string; + completed_before?: string; can_cancel?: boolean; has_failures?: boolean; in_progress?: boolean; + search?: string; + ordering?: string; + page?: number; + page_size?: number; +} + +export interface UserModerationSearchFilters { + query?: string; + role?: UserRole; + has_restrictions?: boolean; + registration_after?: string; + registration_before?: string; + last_activity_before?: string; + risk_level?: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + page?: number; + page_size?: number; } // ============================================================================ -// WebSocket Types +// Park Reviews Types // ============================================================================ -export interface WebSocketMessage { - type: string; - data: any; - timestamp: string; +export interface ParkReview { + id: number; + park: { + id: number; + name: string; + slug: string; + }; + author: { + id: number; + username: string; + display_name: string; + avatar_url: string; + }; + rating: number; // 1-10 scale + title: string; + content: string; + visit_date: string | null; + pros: string[]; + cons: string[]; + categories: { + atmosphere: number | null; // 1-10 + cleanliness: number | null; // 1-10 + staff_friendliness: number | null; // 1-10 + value_for_money: number | null; // 1-10 + food_quality: number | null; // 1-10 + ride_variety: number | null; // 1-10 + }; + recommended_for: ("families" | "thrill_seekers" | "coaster_enthusiasts" | "first_timers" | "couples" | "groups")[]; + crowd_level: "VERY_LOW" | "LOW" | "MODERATE" | "HIGH" | "VERY_HIGH" | null; + weather_conditions: "SUNNY" | "CLOUDY" | "RAINY" | "SNOWY" | "WINDY" | null; + photos: ParkReviewPhoto[]; + helpful_votes: number; + total_votes: number; + user_vote: "HELPFUL" | "NOT_HELPFUL" | null; // Current user's vote + is_verified_visit: boolean; + created_at: string; + updated_at: string; + moderation_status: "APPROVED" | "PENDING" | "FLAGGED" | "REMOVED"; + response_from_park: { + id: number; + content: string; + responder_name: string; + responder_title: string; + created_at: string; + } | null; } -export interface ModerationUpdate extends WebSocketMessage { - type: "moderation_update"; - data: { - event_type: "report_created" | "report_assigned" | "report_resolved" | "queue_updated" | "action_taken"; - object_type: "report" | "queue_item" | "action"; - object_id: number; - object_data: ModerationReport | ModerationQueue | ModerationAction; - user: UserBasic; +export interface ParkReviewPhoto { + id: number; + image_url: string; + image_variants: ImageVariants; + caption: string; + photo_type: "ENTRANCE" | "RIDES" | "FOOD" | "ATMOSPHERE" | "CROWDS" | "OTHER"; + uploaded_at: string; +} + +export interface ParkReviewsResponse { + count: number; + next: string | null; + previous: string | null; + results: ParkReview[]; + summary: { + average_rating: number; + total_reviews: number; + rating_distribution: RatingDistribution; + category_averages: { + atmosphere: number; + cleanliness: number; + staff_friendliness: number; + value_for_money: number; + food_quality: number; + ride_variety: number; + }; + recent_trends: { + last_30_days_average: number; + trend_direction: "UP" | "DOWN" | "STABLE"; + trend_percentage: number; + }; }; } -export interface BulkOperationUpdate extends WebSocketMessage { - type: "bulk_operation_update"; - data: { - operation_id: string; - status: BulkOperation["status"]; - progress_percentage: number; - processed_items: number; - failed_items: number; - estimated_completion: string | null; +export interface CreateParkReview { + rating: number; // 1-10 scale + title: string; + content: string; + visit_date?: string; // YYYY-MM-DD format + pros?: string[]; + cons?: string[]; + categories?: { + atmosphere?: number; // 1-10 + cleanliness?: number; // 1-10 + staff_friendliness?: number; // 1-10 + value_for_money?: number; // 1-10 + food_quality?: number; // 1-10 + ride_variety?: number; // 1-10 }; + recommended_for?: ("families" | "thrill_seekers" | "coaster_enthusiasts" | "first_timers" | "couples" | "groups")[]; + crowd_level?: "VERY_LOW" | "LOW" | "MODERATE" | "HIGH" | "VERY_HIGH"; + weather_conditions?: "SUNNY" | "CLOUDY" | "RAINY" | "SNOWY" | "WINDY"; } -// ============================================================================ -// Form Validation Types -// ============================================================================ +export interface UpdateParkReview { + rating?: number; + title?: string; + content?: string; + visit_date?: string; + pros?: string[]; + cons?: string[]; + categories?: { + atmosphere?: number; + cleanliness?: number; + staff_friendliness?: number; + value_for_money?: number; + food_quality?: number; + ride_variety?: number; + }; + recommended_for?: ("families" | "thrill_seekers" | "coaster_enthusiasts" | "first_timers" | "couples" | "groups")[]; + crowd_level?: "VERY_LOW" | "LOW" | "MODERATE" | "HIGH" | "VERY_HIGH"; + weather_conditions?: "SUNNY" | "CLOUDY" | "RAINY" | "SNOWY" | "WINDY"; +} -export interface ValidationError { - field: string; +export interface ParkReviewVote { + vote_type: "HELPFUL" | "NOT_HELPFUL"; +} + +export interface ParkReviewVoteResponse { + success: boolean; message: string; - code: string; + new_vote: "HELPFUL" | "NOT_HELPFUL" | null; + helpful_votes: number; + total_votes: number; } -export interface FormErrors { - [key: string]: string[] | string; +export interface UploadParkReviewPhoto { + image: File; + caption?: string; + photo_type?: "ENTRANCE" | "RIDES" | "FOOD" | "ATMOSPHERE" | "CROWDS" | "OTHER"; +} + +export interface ParkReviewFilters { + rating_min?: number; + rating_max?: number; + visit_date_after?: string; + visit_date_before?: string; + has_photos?: boolean; + verified_visits_only?: boolean; + crowd_level?: ("VERY_LOW" | "LOW" | "MODERATE" | "HIGH" | "VERY_HIGH")[]; + weather_conditions?: ("SUNNY" | "CLOUDY" | "RAINY" | "SNOWY" | "WINDY")[]; + recommended_for?: ("families" | "thrill_seekers" | "coaster_enthusiasts" | "first_timers" | "couples" | "groups")[]; + ordering?: "newest" | "oldest" | "highest_rated" | "lowest_rated" | "most_helpful"; + page?: number; + page_size?: number; } // ============================================================================ -// Utility Types +// External API Types (merged from thrillwiki-real) // ============================================================================ -export type SortOrder = "asc" | "desc"; - -export interface SortOption { - field: string; - label: string; - order: SortOrder; +// Main entity types from external API +export interface Park { + id: number; + name: string; + slug: string; + location?: Location; + status: string; + description?: string; + average_rating?: number; + coaster_count?: number; + ride_count?: number; + opening_date?: string; + operator?: { id: number; name: string }; + primary_photo?: Photo; + created_at: string; + date_opened: string; } -export interface FilterOption { +export interface Ride { + id: number; + name: string; + slug: string; + category: string; + park?: Park | number; + status: string; + description?: string; + average_rating?: number; + capacity_per_hour?: number; + ride_duration_seconds?: number; + primary_photo?: Photo; + created_at: string; +} + +export interface Company { + id: number; + name: string; + slug: string; + roles: string[]; + notable_parks: { id: number; name: string }[]; + logo_url?: string; + website?: string; + description?: string; + founded_year?: number; + status?: string; + ride_count?: number; + park_count?: number; + headquarters?: Location; + ride_model_count?: number; +} + +export interface Review { + id: number; + user: User; + entity: Entity; + entity_type: 'park' | 'ride'; + rating: number; + text: string; + created_at: string; +} + +// Stats interface from external API +export interface Stats { + total_parks: number; + total_rides: number; + total_manufacturers: number; + total_operators: number; + total_designers: number; + total_property_owners: number; + total_roller_coasters: number; + total_photos: number; + total_park_photos: number; + total_ride_photos: number; + total_reviews: number; + total_park_reviews: number; + total_ride_reviews: number; + roller_coasters: number; + operating_parks: number; + operating_rides: number; + last_updated: string; + relative_last_updated: string; +} + +export interface TrendingItem { + id: number; + name: string; + park: string; + category: string; + rating: number; + rank: number; + views: number; + views_change: string; + slug: string; + date_opened: string; + park_slug?: string; +} + +// User interface from external API +export interface User { + id: string; + username: string; + email: string; + display_name: string; + avatar_url?: string; +} + +// Auth types from external API +export interface RegisterRequest { + email: string; + username: string; + password?: string; + display_name: string; +} + +// Filter types from external API +export interface FilterOptions { + categories?: [string, string][]; + statuses?: [string, string][]; + roles?: [string, string][]; + countries?: string[]; + track_materials?: [string, string][]; + boolean_filters?: BooleanFilter[]; + filter_ranges?: { + height_ft?: FilterRange; + speed_mph?: FilterRange; + rating?: FilterRange; + }; + ordering_options?: OrderingOption[]; +} + +export interface OrderingOption { value: string; label: string; - count?: number; -} - -export interface TabOption { - key: string; - label: string; - count?: number; - badge?: string; -} - -// ============================================================================ -// API Client Configuration Types -// ============================================================================ - -export interface ApiClientConfig { - baseURL: string; - timeout: number; - retries: number; - retryDelay: number; - headers: Record; -} - -export interface RequestConfig { - method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - url: string; - data?: any; - params?: Record; - headers?: Record; - timeout?: number; -} - -export interface CacheConfig { - enabled: boolean; - ttl: number; - maxSize: number; - keyPrefix: string; }