1 OAK MLS

Photo Management

Listing photos with agent uploads and MLS fallback

Photo Management

The platform supports two photo sources: agent-uploaded photos and MLS-synced photos. Agent uploads take priority when available.

Photo Priority System

Photos are resolved in this order:

  1. Agent uploads — Photos in listing_photos table
  2. MLS photos — Photos in listing_media table (from sync)
  3. "Photos Coming Soon" — Placeholder when no photos exist

Why This Order?

  • Quality control — Agents may have better/professional photos
  • MLS limitations — Some MLS providers don't provide photo URLs
  • Customization — Agents can curate which photos appear

Database Tables

listing_photos (Agent Uploads)

CREATE TABLE listing_photos (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id UUID NOT NULL REFERENCES workspaces(id),
  listing_id UUID NOT NULL REFERENCES listings(id),

  -- Storage
  storage_path TEXT NOT NULL,       -- Supabase Storage path
  url TEXT NOT NULL,                -- Public URL
  thumbnail_url TEXT,               -- Resized version (optional)

  -- Metadata
  source TEXT DEFAULT 'upload',     -- 'upload' or 'import'
  sort_order INTEGER DEFAULT 0,
  is_primary BOOLEAN DEFAULT false,
  caption TEXT,
  alt_text TEXT,

  -- File info
  mime_type TEXT,
  size_bytes INTEGER,
  width INTEGER,
  height INTEGER,

  -- Audit
  uploaded_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_listing_photos_listing ON listing_photos(listing_id);
CREATE INDEX idx_listing_photos_primary ON listing_photos(listing_id, is_primary);

listing_media (MLS Sync)

CREATE TABLE listing_media (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id UUID NOT NULL REFERENCES workspaces(id),
  listing_id UUID NOT NULL REFERENCES listings(id),

  url TEXT NOT NULL,
  sort_order INTEGER DEFAULT 0,
  caption TEXT,

  created_at TIMESTAMPTZ DEFAULT NOW()
);

UNIQUE (listing_id, url);

Miami MLS Limitation

The Miami Realtors MLS (via Bridge API) does not provide MediaURL in listing data. Photos must be:

  • Uploaded by the agent, OR
  • Fetched separately via a media endpoint (if available)

This makes agent photo uploads essential for Miami-based workspaces.

Photo API Endpoints

Upload Photo

POST /api/dashboard/photos

Upload a new photo for a listing.

Request: multipart/form-data

file: [binary image data]
listing_id: "listing-uuid"
workspace_id: "workspace-uuid"

Response:

{
  "photo": {
    "id": "photo-uuid",
    "url": "https://storage.../photo.jpg",
    "sort_order": 0,
    "is_primary": true
  }
}

The first photo uploaded becomes primary automatically.

List Photos

GET /api/dashboard/photos?listing_id=xxx

Get all uploaded photos for a listing.

Response:

{
  "photos": [
    {
      "id": "photo-uuid",
      "url": "https://...",
      "sort_order": 0,
      "is_primary": true,
      "caption": null
    }
  ]
}

Delete Photo

DELETE /api/dashboard/photos/[id]

Remove a photo. Deletes from both database and Supabase Storage.

Reorder Photos

POST /api/dashboard/photos/reorder

Update sort order for multiple photos.

Request:

{
  "listing_id": "listing-uuid",
  "order": ["photo-1", "photo-3", "photo-2"]
}

Set Primary Photo

PATCH /api/dashboard/photos/[id]

Update photo metadata including primary status.

Request:

{
  "is_primary": true
}

Setting a photo as primary automatically unsets the previous primary.

Fetching Photos for Display

getListingPhotos()

Returns photos with priority logic applied:

async function getListingPhotos(
  workspaceId: string,
  listingId: string
): Promise<ListingMedia[]> {
  // 1. Check for agent uploads
  const uploadedPhotos = await supabase
    .from('listing_photos')
    .select('*')
    .eq('listing_id', listingId)
    .order('sort_order');

  if (uploadedPhotos.length > 0) {
    return uploadedPhotos.map(toListingMedia);
  }

  // 2. Fall back to MLS photos
  const mlsPhotos = await supabase
    .from('listing_media')
    .select('*')
    .eq('listing_id', listingId)
    .order('sort_order');

  return mlsPhotos;
}

getListingPrimaryPhoto()

Get the primary photo URL with full priority chain:

async function getListingPrimaryPhoto(
  workspaceId: string,
  listingId: string
): Promise<string | null> {
  // 1. Primary uploaded photo
  const primary = await supabase
    .from('listing_photos')
    .select('url')
    .eq('listing_id', listingId)
    .eq('is_primary', true)
    .single();

  if (primary?.url) return primary.url;

  // 2. First uploaded photo
  const firstUpload = await supabase
    .from('listing_photos')
    .select('url')
    .eq('listing_id', listingId)
    .order('sort_order')
    .limit(1)
    .single();

  if (firstUpload?.url) return firstUpload.url;

  // 3. First MLS photo
  const mlsPhoto = await supabase
    .from('listing_media')
    .select('url')
    .eq('listing_id', listingId)
    .order('sort_order')
    .limit(1)
    .single();

  return mlsPhoto?.url ?? null;
}

Supabase Storage Configuration

Photos are stored in Supabase Storage:

  • Bucket: listing-photos
  • Path pattern: {workspace_id}/{listing_id}/{timestamp}.{ext}
  • Access: Public URLs for display

Storage Policy

-- Allow authenticated users to upload to their workspace
CREATE POLICY "Users can upload to their workspace"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
  bucket_id = 'listing-photos' AND
  (storage.foldername(name))[1] IN (
    SELECT id::text FROM workspaces WHERE owner_id = auth.uid()
  )
);

Typesense Integration

The primary photo URL is stored in Typesense for search results:

const doc = {
  id: listing.id,
  primary_photo_url: await getListingPrimaryPhoto(workspaceId, listingId),
  // ... other fields
};

When photos are added/removed, the Typesense document should be updated.

UI Components

Photo Upload in Dashboard

The agent dashboard includes a photo management section:

  • Drag-and-drop upload
  • Photo grid with reordering
  • Primary photo indicator/selector
  • Delete confirmation

The public listing page displays:

  • Primary photo as hero
  • Thumbnail strip for navigation
  • Lightbox for full-size viewing
  • "Photos Coming Soon" placeholder if no photos

Best Practices

  1. Encourage uploads — Prompt agents to upload photos for listings without MLS photos
  2. Optimize images — Resize on upload to reduce storage/bandwidth
  3. Maintain sort order — Let agents control the photo sequence
  4. Clear primary selection — Make it obvious which photo is primary
  5. Graceful fallbacks — Always have a placeholder for missing photos

On this page