Managing Object Images
Managing Object Images
This document outlines the standard procedures for uploading, storing, and managing images associated with spatial objects in the Plings application. Following these guidelines is crucial to ensure data consistency and prevent bugs.
Architecture Decision: Single Source of Truth
IMPORTANT: As of 2025-06-10, image data follows a Single Source of Truth pattern to eliminate database synchronization issues.
✅ SINGLE SOURCE: Supabase
object_imagestable: All image records withpublic_url,is_mainflag- Supabase Storage: Physical file storage in
object-imagesbucket - RLS Policies: Security and ownership enforcement
❌ REMOVED: Duplicate Storage
(removed - was causing sync issues)object_instances.main_image_urlNeo4j(removed - violates separation of concerns)ObjectInstance.mainImageUrl
Rationale
- Security: Leverages Supabase RLS for consistent ownership enforcement
- Performance: Single query to get all image data for an object
- Atomic Operations: Database transactions ensure consistency
- Storage Integration: Tight integration between Supabase Storage and Database
- Architectural Compliance: Neo4j focuses on relationships, Supabase handles metadata/security
Image Storage Location
All images for object instances must be stored in the following Supabase Storage bucket path:
- Bucket:
object-images - Path:
objects/ObjectInstance/{object_id}/{image_filename}
Example:
For an object with the ID c1caa456-6451-4be2-a693-41506d35942a, an image named photo.jpg would be stored at object-images/objects/ObjectInstance/c1caa456-6451-4be2-a693-41506d35942a/photo.jpg.
Using any other bucket or path for object images is deprecated and will lead to inconsistencies.
Important: The bucket name must be exactly object-images (lowercase with hyphen), and the path must include the objects/ prefix within the bucket.
Image Data Model
Database Schema
-- SINGLE SOURCE: All image data in Supabase
CREATE TABLE object_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
object_instance_id UUID REFERENCES object_instances(id) ON DELETE CASCADE,
storage_path TEXT NOT NULL,
public_url TEXT NOT NULL,
is_main BOOLEAN DEFAULT FALSE, -- Identifies the main/primary image
uploaded_by UUID REFERENCES auth.users(id),
uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
file_size INTEGER,
content_type TEXT
);
-- Index for fast main image queries
CREATE INDEX idx_object_images_main ON object_images(object_instance_id, is_main)
WHERE is_main = true;
Main Image Logic
- Only one image per object can have
is_main = true - First uploaded image automatically becomes main image
- When main image is deleted, first remaining image becomes main
- When all images are deleted, no main image exists
GraphQL Response Format
type ObjectInstance {
id: ID!
name: String!
imageUrls: [String!]! // All image URLs from object_images table
mainImageUrl: String // URL of image where is_main = true
}
Backend Implementation
Query Pattern
async def get_object_images(object_id: str) -> Dict[str, Any]:
"""Get all images for an object from single source"""
async with pg_engine.begin() as conn:
# Get all images with main image identification
result = await conn.execute(
text("""
SELECT public_url, is_main
FROM object_images
WHERE object_instance_id = :object_id
ORDER BY uploaded_at ASC
"""),
{"object_id": object_id}
)
images = list(result)
image_urls = [row.public_url for row in images]
main_image_url = next(
(row.public_url for row in images if row.is_main),
None
)
return {
"imageUrls": image_urls,
"mainImageUrl": main_image_url
}
Upload Process
-
Upload to Supabase Storage (
object-imagesbucket)🚨 NEW (2025-06-22): Because the backend is deployed on Vercel serverless functions, each HTTP request body must stay below ≈ 4.5 MB. To stay within this limit the frontend now uploads images one file at a time. The React hook
useImageUploadmakes a separatePOST /register-image/{object_id}call after the binary has been uploaded directly to Supabase Storage. NEVER send the raw image bytes through the backend. - Insert record in
object_imagestable (one row per file, executed inside the same transaction that uploads the file) - Set
is_main = trueif first image for object - Return updated object with image data
Delete Process
- Delete from Supabase Storage
- Delete record from
object_imagestable - If main image was deleted, promote next image to main
- Return updated object with image data
Main Image Management
async def update_main_image_after_deletion(conn, object_id: str, deleted_urls: List[str]):
"""Update main image designation after deletion"""
# Check if main image was deleted
main_deleted = await conn.execute(
text("""
SELECT COUNT(*) FROM object_images
WHERE object_instance_id = :object_id AND is_main = true
"""),
{"object_id": object_id}
)
if main_deleted.scalar() == 0:
# No main image exists, promote the first remaining image
await conn.execute(
text("""
UPDATE object_images
SET is_main = true
WHERE id = (
SELECT id FROM object_images
WHERE object_instance_id = :object_id
ORDER BY uploaded_at ASC
LIMIT 1
)
"""),
{"object_id": object_id}
)
Backend Implementation Features
Atomic Operations
- All image operations are wrapped in database transactions
- Partial failures are rolled back to maintain consistency
- Main image is automatically updated when current main is deleted
Ownership Validation
- Users can only modify images for objects they own
- Organization members can modify organization-owned objects
- Authentication is required for all image operations
Storage Management
- Images are stored in Supabase Storage bucket
object-images - Path format:
objects/ObjectInstance/{objectId}/{uniqueFilename} - Automatic cleanup of storage when images are deleted
- Unique filenames prevent conflicts
Database Consistency
- SINGLE SOURCE:
object_imagestable is the only source of truth - NO DUPLICATION: No image URLs stored in other tables or databases
- RLS Enforcement: Supabase policies ensure security and ownership
Error Handling
Validation
- File type and size validation on upload
- URL validation for deletion operations
- Ownership verification before any modifications
Graceful Failures
- Storage failures don’t break database consistency
- Detailed error messages for debugging
- Automatic rollback on partial failures
Main Image Logic
- First uploaded image becomes main image if none exists
- When main image is deleted, first remaining image becomes main
- Main image is cleared if all images are deleted
- Only one image can be main at any time (enforced by unique constraint)
Migration from Old System
Cleanup Tasks (COMPLETED)
- ✅ Removed orphaned URLs from
object_instances.main_image_url - ✅ Removed orphaned URLs from Neo4j
ObjectInstance.mainImageUrl - ✅ Updated resolvers to use single source pattern
Verification
-- Verify no orphaned references exist
SELECT COUNT(*) FROM object_instances WHERE main_image_url IS NOT NULL; -- Should be 0
// Verify no orphaned references exist in Neo4j
MATCH (o:ObjectInstance) WHERE o.mainImageUrl IS NOT NULL RETURN COUNT(o); // Should be 0
Best Practices
- Always query
object_imagestable for image data - Never store image URLs in other tables or databases
- Use
is_mainflag to identify primary image - Wrap all image operations in database transactions
- Validate ownership before any modifications
- Clean up storage when deleting database records
Troubleshooting
Common Issues
- “No main image” errors: Check if any image has
is_main = true - Multiple main images: Run constraint fix to ensure only one main
- Missing images: Verify storage cleanup didn’t remove wrong files
- Permission errors: Check RLS policies and user ownership
Debug Queries
-- Check main image status for an object
SELECT id, public_url, is_main, uploaded_at
FROM object_images
WHERE object_instance_id = 'your-object-id'
ORDER BY uploaded_at;
-- Find objects with multiple main images (should be empty)
SELECT object_instance_id, COUNT(*)
FROM object_images
WHERE is_main = true
GROUP BY object_instance_id
HAVING COUNT(*) > 1;
New GraphQL Image Mutations (v2)
The backend now provides dedicated GraphQL mutations for comprehensive image management:
Upload New Images
mutation uploadObjectImages($objectId: ID!, $images: [Upload!]!) {
uploadObjectImages(objectId: $objectId, images: $images) {
id
imageUrls
mainImageUrl
}
}
Delete Specific Images
mutation deleteObjectImages($objectId: ID!, $imageUrls: [String!]!) {
deleteObjectImages(objectId: $objectId, imageUrls: $imageUrls) {
id
imageUrls
mainImageUrl
}
}
Update Images (Add + Delete in one operation)
mutation updateObjectImages($objectId: ID!, $newImages: [Upload!], $deleteImageUrls: [String!]) {
updateObjectImages(objectId: $objectId, newImages: $newImages, deleteImageUrls: $deleteImageUrls) {
id
imageUrls
mainImageUrl
}
}
Direct-to-Storage Upload (v3)
Starting 2025-06-23 the recommended flow eliminates the backend size limit entirely:
- Frontend uploads the binary straight to Supabase Storage using the JavaScript client SDK.
- On success it constructs
storage_pathand obtainspublic_urlviagetPublicUrl(). - The client immediately calls the lightweight REST endpoint
POST /register-image/{object_id}with JSON{ public_url, storage_path }. - Backend validates ownership, writes the
object_imagesrow (single-source-of-truth), ensures amainimage exists, and returns the updated object image set.
This pattern scales to hundreds of images without touching the Vercel request-size ceiling because only tiny JSON payloads hit the backend.
CDN Image Transformations (Frontend)
Supabase’s Storage CDN supports on-the-fly image resizing, cropping and format conversion. Always use the CDN transform API when displaying large images in the UI—it saves bandwidth and dramatically speeds up page loads.
Quick Example – Contained Thumbnail
import { supabase } from '@/lib/supabase';
const getThumbnail = async (bucket: string, path: string) => {
const { data, error } = await supabase.storage
.from(bucket)
.download(path, {
transform: {
width: 400,
height: 400,
resize: 'contain', // 'cover' | 'fill' | 'inside' | 'outside'
quality: 80,
},
});
if (error) throw error;
return URL.createObjectURL(data);
};
Supported Options (subset)
| Property | Type | Default | Notes |
|---|---|---|---|
width |
number |
original | Target width in pixels |
height |
number |
original | Target height in pixels |
resize |
'contain' \| 'cover' \| 'fill' \| 'inside' \| 'outside' |
'cover' |
Behaviour identical to CSS object-fit |
quality |
number (1-100) |
80 | JPEG/WebP quality |
format |
'jpg' \| 'png' \| 'webp' |
source | Force output format |
Best-Practice Guidelines
- Never download the original 5-10 MB photo for thumbnails or gallery previews. Use
width/heightto down-sample. - Prefer
object-fit: cover+resize: 'cover'for square thumbnails. - Store the unmodified original; transformations are cached by the CDN so there’s no storage duplication.
- Leave critical hero images uncached in development by appending
?v=${Date.now()}while iterating on the UI.
See Supabase docs → Storage → Image Transformations for the full parameter list.