# 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.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

```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.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
