# Image Upload and Display Fix - January 8, 2025 ## Overview Fixed a critical issue where images were uploading successfully to Cloudflare R2 but not displaying in the app feed. The root cause was that feed edge functions were filtering out `image_url` from API responses despite querying it from the database. ## Problem Description ### Symptoms - Images uploaded successfully to Cloudflare R2 - Image URLs saved correctly to the `posts.image_url` database column - Images did NOT display in the app feed - No error messages or visual indicators ### Root Cause Analysis The issue occurred in two edge functions (`feed-sojorn` and `feed-personal`) that manually construct JSON responses. While both functions included `image_url` in their SQL SELECT queries, they filtered it out when mapping the database results to the final JSON response sent to the Flutter app. **Example from `feed-sojorn/index.ts` (lines 110-115):** ```typescript // SQL query INCLUDED image_url ✅ .select(`id, body, created_at, tone_label, allow_chain, chain_parent_id, image_url, ...`) // BUT response mapping EXCLUDED it ❌ const orderedPosts = resultIds.map(...).map((post: Post) => ({ id: post.id, body: post.body, created_at: post.created_at, // ... image_url was missing here! })); ``` ## Files Modified ### 1. Backend Edge Functions #### `supabase/functions/feed-sojorn/index.ts` **Changes:** - **Line 13**: Added `image_url: string | null;` to the `Post` TypeScript interface - **Line 112**: Added `image_url: post.image_url,` to the response mapping in `orderedPosts` **Before:** ```typescript interface Post { id: string; body: string; created_at: string; category_id: string; tone_label: "positive" | "neutral" | "mixed" | "negative"; cis_score: number; author_id: string; author: Profile; category: Category; metrics: PostMetrics | null; allow_chain: boolean; chain_parent_id: string | null; user_liked: { user_id: string }[]; user_saved: { user_id: string }[]; } const orderedPosts = resultIds.map((id) => finalPosts?.find((p: Post) => p.id === id)).filter(Boolean).map((post: Post) => ({ id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id, author: post.author, category: post.category, metrics: post.metrics, user_liked: post.user_liked?.some((l: PostLike) => l.user_id === user.id) || false, user_saved: post.user_saved?.some((s: PostSave) => s.user_id === user.id) || false, })); ``` **After:** ```typescript interface Post { id: string; body: string; created_at: string; category_id: string; tone_label: "positive" | "neutral" | "mixed" | "negative"; cis_score: number; author_id: string; author: Profile; category: Category; metrics: PostMetrics | null; allow_chain: boolean; chain_parent_id: string | null; image_url: string | null; // ✅ ADDED user_liked: { user_id: string }[]; user_saved: { user_id: string }[]; } const orderedPosts = resultIds.map((id) => finalPosts?.find((p: Post) => p.id === id)).filter(Boolean).map((post: Post) => ({ id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id, image_url: post.image_url, // ✅ ADDED author: post.author, category: post.category, metrics: post.metrics, user_liked: post.user_liked?.some((l: PostLike) => l.user_id === user.id) || false, user_saved: post.user_saved?.some((s: PostSave) => s.user_id === user.id) || false, })); ``` #### `supabase/functions/feed-personal/index.ts` **Changes:** - **Line 70**: Added `image_url: post.image_url,` to the response mapping in `feedItems` **Before:** ```typescript const feedItems = postsWithChains.map((post: any) => ({ id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id, chain_parent: post.chain_parent_id ? chainParentMap.get(post.chain_parent_id) : null, author: post.author, category: post.category, metrics: post.metrics, user_liked: post.user_liked?.some((l: any) => l.user_id === user.id) || false, user_saved: post.user_saved?.some((s: any) => s.user_id === user.id) || false, })); ``` **After:** ```typescript const feedItems = postsWithChains.map((post: any) => ({ id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id, image_url: post.image_url, // ✅ ADDED chain_parent: post.chain_parent_id ? chainParentMap.get(post.chain_parent_id) : null, author: post.author, category: post.category, metrics: post.metrics, user_liked: post.user_liked?.some((l: any) => l.user_id === user.id) || false, user_saved: post.user_saved?.some((s: any) => s.user_id === user.id) || false, })); ``` ### 2. Frontend Flutter App #### `sojorn_app/lib/widgets/post/post_media.dart` **Changes:** - Modified to accept and render `post.imageUrl` - Added explicit height constraint (300px) to the image container - Enhanced error handling with visual indicators - Added loading state with progress indicator **Key improvements:** ```dart // Added post parameter to widget class PostMedia extends StatelessWidget { final Post? post; final Widget? child; const PostMedia({ super.key, this.post, this.child, }); @override Widget build(BuildContext context) { // Render image if post has image_url if (post != null && post!.imageUrl != null && post!.imageUrl!.isNotEmpty) { return Padding( padding: const EdgeInsets.only(top: AppTheme.spacingSm), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Debug banner (to be removed) Container( padding: const EdgeInsets.all(8), color: Colors.blue, width: double.infinity, child: Text('IMAGE: ${post!.imageUrl}', style: const TextStyle(color: Colors.white, fontSize: 8)), ), const SizedBox(height: 8), // Image with explicit height constraint SizedBox( height: 300, width: double.infinity, child: ClipRRect( borderRadius: BorderRadius.circular(AppTheme.radiusMd), child: Image.network( post!.imageUrl!, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; // Show loading indicator return Container( color: Colors.pink.withOpacity(0.3), child: Center(child: CircularProgressIndicator(...)), ); }, errorBuilder: (context, error, stackTrace) { // Show error state return Container( color: Colors.red.withOpacity(0.3), child: Center( child: Column( children: [ Icon(Icons.broken_image, size: 48), Text('Error: $error'), ], ), ), ); }, ), ), ), ], ), ); } // ... fallback logic } } ``` #### `sojorn_app/lib/widgets/post_card.dart` **Changes:** - **Line 66**: Modified to pass `post` to `PostMedia` widget **Before:** ```dart PostHeader(post: post), const SizedBox(height: AppTheme.spacingMd), PostBody(text: post.body), const PostMedia(), // ❌ Not receiving post data const SizedBox(height: AppTheme.spacingMd), ``` **After:** ```dart PostHeader(post: post), const SizedBox(height: AppTheme.spacingMd), PostBody(text: post.body), PostMedia(post: post), // ✅ Now receives post data const SizedBox(height: AppTheme.spacingMd), ``` ## Debugging Process ### 1. Initial Investigation - Verified image upload functionality was working (images in R2 bucket ✅) - Verified database had `image_url` values (4 posts with images ✅) - Confirmed edge function SELECT queries included `image_url` (✅) ### 2. Discovery Phase Added debug logging to trace data flow: **In `Post.fromJson()` (sojorn_app/lib/models/post.dart:120-126):** ```dart if (json['image_url'] != null) { print('DEBUG Post.fromJson: Found image_url in JSON: ${json['image_url']}'); } else { print('DEBUG Post.fromJson: No image_url in JSON for post ${json['id']}'); print('DEBUG Post.fromJson: Available keys: ${json.keys.toList()}'); } ``` **In `PostMedia` widget (sojorn_app/lib/widgets/post/post_media.dart:19-24):** ```dart if (post != null) { debugPrint('PostMedia: post.imageUrl = ${post!.imageUrl}'); } if (post != null && post!.imageUrl != null && post!.imageUrl!.isNotEmpty) { debugPrint('PostMedia: SHOWING IMAGE for ${post!.imageUrl}'); // ... render image } ``` ### 3. Key Finding Console logs revealed two different response structures: **feed-sojorn response (missing image_url):** ``` DEBUG Post.fromJson: No image_url in JSON for post f194f92b-... Available keys: [id, body, created_at, tone_label, allow_chain, chain_parent_id, author, category, metrics, user_liked, user_saved] ``` **Other feed response (has image_url):** ``` DEBUG Post.fromJson: Found image_url in JSON: https://media.gosojorn.com/88a7cc72-... Available keys: [id, body, author_id, category_id, tone_label, cis_score, status, created_at, edited_at, deleted_at, allow_chain, chain_parent_id, image_url, chain_parent, metrics, author] ``` This confirmed the edge functions were filtering out `image_url`. ## Testing & Verification ### Before Fix ``` I/flutter: DEBUG Post.fromJson: No image_url in JSON for post f194f92b-... I/flutter: PostMedia: post.imageUrl = null ``` ### After Fix ``` I/flutter: DEBUG Post.fromJson: Found image_url in JSON: https://media.gosojorn.com/88a7cc72-... I/flutter: PostMedia: post.imageUrl = https://media.gosojorn.com/88a7cc72-... I/flutter: PostMedia: SHOWING IMAGE for https://media.gosojorn.com/88a7cc72-... I/flutter: PostMedia: Image loading... 8899 / 275401 I/flutter: PostMedia: Image LOADED successfully ``` ## Deployment ```bash npx supabase functions deploy feed-sojorn feed-personal --no-verify-jwt ``` **Note:** The `--no-verify-jwt` flag is required because the app uses ES256 JWT tokens which are not compatible with the default Supabase edge function JWT validation. ## Related Context ### Image Upload Flow (Already Working) 1. User selects image in `ComposeScreen` 2. Image uploaded via `ImageUploadService.uploadImage()` to Cloudflare R2 3. Returns public URL: `https://media.gosojorn.com/{uuid}.jpg` 4. URL sent to `publish-post` edge function 5. Saved to `posts.image_url` column ### Feed Display Flow (Now Fixed) 1. App calls `getSojornFeed()` or `getPersonalFeed()` 2. Edge functions query database (includes `image_url`) 3. **[FIXED]** Edge functions now include `image_url` in response JSON 4. Flutter `Post.fromJson()` parses `image_url` 5. `PostCard` passes `post` to `PostMedia` 6. `PostMedia` renders image using `Image.network()` ## Cleanup Tasks The following debug code should be removed in a future commit: 1. **`sojorn_app/lib/models/post.dart` (lines 120-126)**: Remove debug logging in `Post.fromJson()` 2. **`sojorn_app/lib/widgets/post/post_media.dart` (lines 31-36)**: Remove blue debug banner 3. **`sojorn_app/lib/widgets/post/post_media.dart` (lines 19-20, 24, 48, 51, 64-65)**: Remove debug print statements ## Lessons Learned 1. **Manual Response Mapping**: When edge functions manually construct JSON responses (rather than returning raw database results), every field must be explicitly included 2. **Debug Logging**: Adding strategic debug logs at data transformation boundaries (JSON parsing, API responses) quickly identified where data was being lost 3. **TypeScript Interfaces**: TypeScript interfaces in edge functions should match the database schema to catch missing fields at compile time 4. **Widget Data Flow**: Flutter widgets that display data must receive that data as parameters - `const` constructors without parameters cannot access dynamic data ## Success Criteria - ✅ Images upload to Cloudflare R2 - ✅ Image URLs save to database - ✅ Feed APIs return `image_url` in JSON - ✅ Flutter app parses `image_url` from JSON - ✅ PostMedia widget receives post data - ✅ Images display in app feed with loading states - ✅ Error handling shows broken image icon on failure ## Future Improvements 1. Remove debug code and banners 2. Consider using `AspectRatio` widget instead of fixed height for images 3. Add image caching to improve performance 4. Implement progressive image loading with thumbnails 5. Add image alt text support for accessibility