sojorn/sojorn_docs/troubleshooting/image-upload-fix-2025-01-08.md

13 KiB

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):

// 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:

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:

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:

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:

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:

// 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:

PostHeader(post: post),
const SizedBox(height: AppTheme.spacingMd),
PostBody(text: post.body),
const PostMedia(),  // ❌ Not receiving post data
const SizedBox(height: AppTheme.spacingMd),

After:

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):

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):

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.sojorn.net/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.sojorn.net/88a7cc72-...
I/flutter: PostMedia: post.imageUrl = https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: SHOWING IMAGE for https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: Image loading... 8899 / 275401
I/flutter: PostMedia: Image LOADED successfully

Deployment

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.

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.sojorn.net/{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