feat: Implement avatar upload system with Cloudflare integration

- Added migration to transition avatar data from CloudflareImageField to ForeignKey structure in UserProfile.
- Fixed UserProfileEvent avatar field to align with new avatar structure.
- Created serializers for social authentication, including connected and available providers.
- Developed request logging middleware for comprehensive request/response logging.
- Updated moderation and parks migrations to remove outdated triggers and adjust foreign key relationships.
- Enhanced rides migrations to ensure proper handling of image uploads and triggers.
- Introduced a test script for the 3-step avatar upload process, ensuring functionality with Cloudflare.
- Documented the fix for avatar upload issues, detailing root cause, implementation, and verification steps.
- Implemented automatic deletion of Cloudflare images upon avatar, park, and ride photo changes or removals.
This commit is contained in:
pacnpal
2025-08-30 21:20:25 -04:00
parent fb6726f89a
commit 9bed782784
75 changed files with 4571 additions and 1962 deletions

View File

@@ -7,6 +7,9 @@ import type {
LoginResponse,
SignupRequest,
SignupResponse,
EmailVerificationResponse,
ResendVerificationRequest,
ResendVerificationResponse,
LogoutResponse,
CurrentUserResponse,
PasswordResetRequest,
@@ -16,6 +19,20 @@ import type {
SocialProvidersResponse,
AuthStatusResponse,
// Django-CloudflareImages-Toolkit Types
CloudflareImage,
CloudflareImageUploadRequest,
CloudflareImageUploadResponse,
CloudflareDirectUploadRequest,
CloudflareDirectUploadResponse,
CloudflareImageVariant,
CloudflareImageStats,
CloudflareImageListResponse,
CloudflareImageDeleteResponse,
CloudflareWebhookPayload,
EnhancedPhoto,
EnhancedImageVariants,
// Social Provider Management Types
ConnectedProvider,
AvailableProvider,
@@ -111,6 +128,9 @@ import type {
CreateParkRequest,
ParkDetail,
ParkFilterOptions,
ParkSearchFilters,
ParkCompanySearchResponse,
ParkSearchSuggestionsResponse,
ParkImageSettings,
ParkPhotosResponse,
UploadParkPhoto,
@@ -347,13 +367,25 @@ export const authApi = {
body: JSON.stringify(data),
});
// Store access token on successful signup
setAuthToken(response.access);
// Store refresh token separately
setRefreshToken(response.refresh);
// Only store tokens if email verification is not required
if (response.access && response.refresh) {
setAuthToken(response.access);
setRefreshToken(response.refresh);
}
return response;
},
async verifyEmail(token: string): Promise<EmailVerificationResponse> {
return makeRequest<EmailVerificationResponse>(`/auth/verify-email/${token}/`);
},
async resendVerificationEmail(data: ResendVerificationRequest): Promise<ResendVerificationResponse> {
return makeRequest<ResendVerificationResponse>('/auth/resend-verification/', {
method: 'POST',
body: JSON.stringify(data),
});
},
async refreshToken(): Promise<{ access: string; refresh: string }> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
@@ -547,6 +579,13 @@ export const accountApi = {
});
},
async saveAvatarImage(data: { cloudflare_image_id: string }): Promise<AvatarUploadResponse> {
return makeRequest<AvatarUploadResponse>('/accounts/profile/avatar/save/', {
method: 'POST',
body: JSON.stringify(data),
});
},
async getPreferences(): Promise<UserPreferences> {
return makeRequest<UserPreferences>('/accounts/preferences/');
},
@@ -700,18 +739,13 @@ export const accountApi = {
// ============================================================================
export const parksApi = {
async getParks(params?: {
page?: number;
page_size?: number;
search?: string;
country?: string;
state?: string;
ordering?: string;
}): Promise<ParkListResponse> {
async getParks(params?: ParkSearchFilters): Promise<ParkListResponse> {
const searchParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
// Note: continent and park_type parameters are accepted but ignored by backend
// due to missing model fields (ParkLocation has no continent, Park has no park_type)
searchParams.append(key, value.toString());
}
});
@@ -749,12 +783,12 @@ export const parksApi = {
return makeRequest<ParkFilterOptions>('/parks/filter-options/');
},
async searchCompanies(query: string): Promise<CompanySearchResponse> {
return makeRequest<CompanySearchResponse>(`/parks/search/companies/?q=${encodeURIComponent(query)}`);
async searchCompanies(query: string): Promise<ParkCompanySearchResponse> {
return makeRequest<ParkCompanySearchResponse>(`/parks/search/companies/?q=${encodeURIComponent(query)}`);
},
async getSearchSuggestions(query: string): Promise<SearchSuggestionsResponse> {
return makeRequest<SearchSuggestionsResponse>(`/parks/search-suggestions/?q=${encodeURIComponent(query)}`);
async getSearchSuggestions(query: string): Promise<ParkSearchSuggestionsResponse> {
return makeRequest<ParkSearchSuggestionsResponse>(`/parks/search-suggestions/?q=${encodeURIComponent(query)}`);
},
async setParkImages(parkId: number, data: ParkImageSettings): Promise<ParkDetail> {
@@ -807,6 +841,13 @@ export const parksApi = {
method: 'DELETE',
});
},
async saveParkPhoto(parkId: number, data: { cloudflare_image_id: string; caption?: string; alt_text?: string; photo_type?: string; is_primary?: boolean }): Promise<any> {
return makeRequest(`/parks/${parkId}/photos/save_image/`, {
method: 'POST',
body: JSON.stringify(data),
});
},
};
// ============================================================================
@@ -927,6 +968,13 @@ export const ridesApi = {
async getManufacturerRideModels(manufacturerSlug: string): Promise<ManufacturerRideModels> {
return makeRequest<ManufacturerRideModels>(`/rides/manufacturers/${manufacturerSlug}/`);
},
async saveRidePhoto(rideId: number, data: { cloudflare_image_id: string; caption?: string; alt_text?: string; photo_type?: string; is_primary?: boolean }): Promise<any> {
return makeRequest(`/rides/${rideId}/photos/save_image/`, {
method: 'POST',
body: JSON.stringify(data),
});
},
};
// ============================================================================
@@ -1692,6 +1740,247 @@ export const parkReviewsApi = {
},
};
// ============================================================================
// Django-CloudflareImages-Toolkit API (Updated to match actual endpoints)
// ============================================================================
export const cloudflareImagesApi = {
// Direct Upload Flow - Get temporary upload URL
async createDirectUploadUrl(data?: CloudflareDirectUploadRequest): Promise<CloudflareDirectUploadResponse> {
return makeRequest<CloudflareDirectUploadResponse>('/cloudflare-images/api/upload-url/', {
method: 'POST',
body: JSON.stringify(data || {}),
});
},
// Upload image using temporary URL (client-side to Cloudflare)
async uploadToCloudflare(uploadUrl: string, file: File, metadata?: Record<string, any>): Promise<CloudflareImageUploadResponse> {
const formData = new FormData();
formData.append('file', file);
if (metadata) {
Object.entries(metadata).forEach(([key, value]) => {
formData.append(`metadata[${key}]`, value.toString());
});
}
// Upload directly to Cloudflare (bypasses our API)
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.errors?.[0]?.message || `Upload failed: ${response.status}`,
response.status,
errorData
);
}
return await response.json();
},
// Complete upload flow - get URL then upload
async uploadImage(file: File, options?: {
expiry_minutes?: number; // Minutes until upload URL expires
metadata?: Record<string, any>;
require_signed_urls?: boolean;
filename?: string;
}): Promise<{
uploadResponse: CloudflareImageUploadResponse;
directUploadResponse: CloudflareDirectUploadResponse;
}> {
// Step 1: Get temporary upload URL
const directUploadResponse = await this.createDirectUploadUrl({
expiry_minutes: options?.expiry_minutes,
metadata: options?.metadata,
require_signed_urls: options?.require_signed_urls,
filename: options?.filename,
});
// Step 2: Upload to Cloudflare
const uploadResponse = await this.uploadToCloudflare(
directUploadResponse.upload_url,
file,
options?.metadata
);
return { uploadResponse, directUploadResponse };
},
// List images with pagination
async listImages(params?: {
status?: "pending" | "uploaded" | "failed" | "expired";
limit?: number;
offset?: number;
}): Promise<CloudflareImageListResponse> {
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<CloudflareImageListResponse>(`/cloudflare-images/api/images/${query ? `?${query}` : ''}`);
},
// Get image details
async getImage(imageId: string): Promise<CloudflareImage> {
return makeRequest<CloudflareImage>(`/cloudflare-images/api/images/${imageId}/`);
},
// Check image status
async checkImageStatus(imageId: string): Promise<CloudflareImage> {
return makeRequest<CloudflareImage>(`/cloudflare-images/api/images/${imageId}/check_status/`, {
method: 'POST',
});
},
// Update image metadata
async updateImage(imageId: string, data: {
metadata?: Record<string, any>;
require_signed_urls?: boolean;
}): Promise<CloudflareImage> {
return makeRequest<CloudflareImage>(`/cloudflare-images/api/images/${imageId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
// Delete image
async deleteImage(imageId: string): Promise<CloudflareImageDeleteResponse> {
return makeRequest<CloudflareImageDeleteResponse>(`/cloudflare-images/api/images/${imageId}/`, {
method: 'DELETE',
});
},
// Get account statistics
async getStats(): Promise<CloudflareImageStats> {
return makeRequest<CloudflareImageStats>('/cloudflare-images/api/stats/');
},
// Get available variants
async getVariants(): Promise<CloudflareImageVariant[]> {
return makeRequest<CloudflareImageVariant[]>('/cloudflare-images/api/variants/');
},
// Create new variant
async createVariant(data: Omit<CloudflareImageVariant, 'id'>): Promise<CloudflareImageVariant> {
return makeRequest<CloudflareImageVariant>('/cloudflare-images/api/variants/', {
method: 'POST',
body: JSON.stringify(data),
});
},
// Update variant
async updateVariant(variantId: string, data: Partial<Omit<CloudflareImageVariant, 'id'>>): Promise<CloudflareImageVariant> {
return makeRequest<CloudflareImageVariant>(`/cloudflare-images/api/variants/${variantId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
// Delete variant
async deleteVariant(variantId: string): Promise<void> {
return makeRequest<void>(`/cloudflare-images/api/variants/${variantId}/`, {
method: 'DELETE',
});
},
// Webhook handling (for server-side processing)
async processWebhook(payload: CloudflareWebhookPayload): Promise<{ success: boolean; message: string }> {
return makeRequest('/cloudflare-images/api/webhook/', {
method: 'POST',
body: JSON.stringify(payload),
});
},
// Utility functions for URL generation
generateImageUrl(imageId: string, variant: string = 'public'): string {
// This would typically use your Cloudflare account hash
// The actual implementation would get this from your backend configuration
return `/cloudflare-images/serve/${imageId}/${variant}`;
},
generateSignedUrl(imageId: string, variant: string = 'public', expiryMinutes: number = 60): Promise<{ url: string; expires_at: string }> {
return makeRequest(`/cloudflare-images/api/signed-url/`, {
method: 'POST',
body: JSON.stringify({
image_id: imageId,
variant,
expiry_minutes: expiryMinutes,
}),
});
},
// Batch operations
async batchDelete(imageIds: string[]): Promise<{
success: string[];
failed: Array<{ id: string; error: string }>;
}> {
return makeRequest('/cloudflare-images/api/batch/delete/', {
method: 'POST',
body: JSON.stringify({ image_ids: imageIds }),
});
},
async batchUpdateMetadata(updates: Array<{
image_id: string;
metadata: Record<string, any>;
}>): Promise<{
success: string[];
failed: Array<{ id: string; error: string }>;
}> {
return makeRequest('/cloudflare-images/api/batch/update-metadata/', {
method: 'POST',
body: JSON.stringify({ updates }),
});
},
// Search and filter images
async searchImages(params: {
metadata_key?: string;
metadata_value?: string;
uploaded_after?: string; // ISO date
uploaded_before?: string; // ISO date
filename_contains?: string;
page?: number;
per_page?: number;
}): Promise<CloudflareImageListResponse> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, value.toString());
}
});
const query = searchParams.toString();
return makeRequest<CloudflareImageListResponse>(`/cloudflare-images/api/search/${query ? `?${query}` : ''}`);
},
// Cleanup expired upload URLs
async cleanupExpiredUploads(): Promise<{
cleaned_count: number;
message: string;
}> {
return makeRequest('/cloudflare-images/api/cleanup/', {
method: 'POST',
});
},
// Backward compatibility aliases
getDirectUploadUrl: function(data?: CloudflareDirectUploadRequest): Promise<CloudflareDirectUploadResponse> {
return this.createDirectUploadUrl(data);
},
};
// ============================================================================
// History API
// ============================================================================
@@ -2168,6 +2457,7 @@ export default {
userModeration: userModerationApi,
bulkOperations: bulkOperationsApi,
parkReviews: parkReviewsApi,
cloudflareImages: cloudflareImagesApi,
external: externalApi,
utils: apiUtils,
};