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:
- Agent uploads — Photos in
listing_photostable - MLS photos — Photos in
listing_mediatable (from sync) - "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
Photo Gallery on Listing Detail
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
- Encourage uploads — Prompt agents to upload photos for listings without MLS photos
- Optimize images — Resize on upload to reduce storage/bandwidth
- Maintain sort order — Let agents control the photo sequence
- Clear primary selection — Make it obvious which photo is primary
- Graceful fallbacks — Always have a placeholder for missing photos