338 lines
13 KiB
Markdown
338 lines
13 KiB
Markdown
# 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
|