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_images table: All image records with public_url, is_main flag
  • Supabase Storage: Physical file storage in object-images bucket
  • RLS Policies: Security and ownership enforcement

❌ REMOVED: Duplicate Storage

  • object_instances.main_image_url (removed - was causing sync issues)
  • Neo4j ObjectInstance.mainImageUrl (removed - violates separation of concerns)

Rationale

  1. Security: Leverages Supabase RLS for consistent ownership enforcement
  2. Performance: Single query to get all image data for an object
  3. Atomic Operations: Database transactions ensure consistency
  4. Storage Integration: Tight integration between Supabase Storage and Database
  5. 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

  1. Upload to Supabase Storage (object-images bucket)

    🚨 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 useImageUpload makes a separate POST /register-image/{object_id} call after the binary has been uploaded directly to Supabase Storage. NEVER send the raw image bytes through the backend.

  2. Insert record in object_images table (one row per file, executed inside the same transaction that uploads the file)
  3. Set is_main = true if first image for object
  4. Return updated object with image data

Delete Process

  1. Delete from Supabase Storage
  2. Delete record from object_images table
  3. If main image was deleted, promote next image to main
  4. 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_images table 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

  1. Always query object_images table for image data
  2. Never store image URLs in other tables or databases
  3. Use is_main flag to identify primary image
  4. Wrap all image operations in database transactions
  5. Validate ownership before any modifications
  6. 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:

  1. Frontend uploads the binary straight to Supabase Storage using the JavaScript client SDK.
  2. On success it constructs storage_path and obtains public_url via getPublicUrl().
  3. The client immediately calls the lightweight REST endpoint POST /register-image/{object_id} with JSON { public_url, storage_path }.
  4. Backend validates ownership, writes the object_images row (single-source-of-truth), ensures a main image 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

  1. Never download the original 5-10 MB photo for thumbnails or gallery previews. Use width/height to down-sample.
  2. Prefer object-fit: cover + resize: 'cover' for square thumbnails.
  3. Store the unmodified original; transformations are cached by the CDN so there’s no storage duplication.
  4. 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.