Image upload architecture

From the camera roll to the storefront — upload, analysis, attachment, and delivery

The shape

An image exists before the listing it ends up on. The browser uploads the bytes, the server registers a row, and only on submit does that row get claimed by a listing.

seller picks photos
   ↓  browser → Supabase Storage (bytes, direct)
   ↓  POST /api/v1/upload/images   → listing_images row, screening, colour, dimensions
   ↓  AI analysis kicks off on the cover
seller submits the form
   ↓  activateSessionImages(tx)    → rows claimed, positions + transforms written
listing published

An unclaimed row is harmless: it belongs to its uploader, is invisible to everyone else, and a cron sweeps abandoned ones with their storage objects.

Upload

The bytes go straight from the browser to Supabase Storage — they never pass through a server action. The dropzone filters MIME types up front against what the bucket allows (jpg, png, webp, gif), because storage would otherwise reject them after the transfer.

Once the object is in place, the client registers it:

POST /api/v1/upload/images
{ url, width, height, itemId?, uploadSessionId?, metadata }
→ { id, url, width, height, dominantColor, dominantColorOpaque, isNsfw, uploadSessionId }

The route is what screens the photo and derives the colour (see Screening and analysis). An image flagged NSFW is stored, blurred and labelled on its tile, and the client is told; a listing holding one cannot publish.

Dimensions are read in the browser, which is also what applies EXIF auto-rotation — so the numbers stored are the ones a viewer will actually see.

Uploads run concurrently; a failure in one does not lose the others.

Attachment, not status

There is no status column. Attachment is the state:

listingIdMeans
nullUploaded, not yet claimed. Visible only to the uploader through RLS
setPart of that listing

uploadSessionId groups the photos of one form session before a listing exists.

On submit, activateSessionImages(tx) runs inside the save transaction:

  • Removed images are retired and returned to the caller.
  • Each submitted image is claimed with its position, rotation, crop and zoom.
  • listings.imageUrl is set from position 0.
  • A submitted id that is not the user's own, and not free or already on this listing, throws — the whole save rolls back rather than publishing a listing whose photos are not what was submitted.

Storage objects outlive the transaction on purpose: they are purged only after the commit, because a rolled-back save must not have already thrown the bytes away.

This is also why an image on someone else's listing can never be moved by naming its id.

Screening and analysis

Two separate passes read a photo. Cloud Vision screens every photo at upload, and its answer is stored on the image row. Gemini reads the cover when the form asks what the photos say, and its answer fills the form.

At upload: Cloud Vision

register-uploaded-image.service.ts sends each photo to Cloud Vision (packages/ai/lib/vision-client.ts) with four features, every one of them read:

FeatureBecomes
Safe searchis_nsfw, when adult or violence is LIKELY or VERY_LIKELY
Labels, objectsis_suspicious, when one names something Cüte never sells (prohibitedPhotoTerms: firearms and ammunition, explosives, drugs, syringes)
Image propertiesdominant_color, for the tile and the blur

Labels and objects scoring under 0.6 are dropped as guesses. A suspicious photo is flagged for staff, never refused: Vision's words are a guess, and a toy pistol deserves a person's eye. Knives and tools are not on the list, because Kitchen knives and Tools are categories.

Vision fails open: if it is down, the row is stored unchecked (metadata.visionChecked false) and publish re-screens it (recheck-unverified-images.service.ts) before the listing can go live.

When the form asks: Gemini

analyzeListingPhotos (features/listings/services/photo-analysis/) answers one question: what do the photos say? It does not decide how the form behaves.

  • The cover is read once. Gemini's answer is stored in listing_image_analyses, so a retry, a reorder or a second photo costs no vision call. The other photos add only their dominant colours.
  • The department narrows the search. Gemini names the catalogue's top level (category-departments.ts), and the caption is matched only among that department's leaves, so rain boots are never filed under Kids. not_offered stops there: the form says it couldn't find a category.
  • A weak match is not applied. Below the sanity floor (SEARCH_CONFIG.PHOTO_CATEGORY_SANITY_FLOOR) the closest leaves are offered as suggestions and the seller picks.
  • The seller's own edits win. The form fills only fields that are empty or still hold the AI's last answer (use-photo-analysis.ts).

The sell form shows this as one screen: from the first pick, through the upload and the read, to the filled form. Later photos re-read quietly; a new cover opens the screen again.

Delivery

Nothing is pre-resized. Images are served through Cloudflare Image Transformations (/cdn-cgi/image/), which resizes, crops, rotates and converts format on the fly.

getOptimizedImageUrl() takes the stored transform as options and builds the Cloudflare URL:

getOptimizedImageUrl(src, "card", { rotation: 90, crop: "4:5", gravity: "auto" })
   ↓
…/cdn-cgi/image/width=600,quality=75,format=auto,fit=cover,height=750,gravity=auto,rotate=90/…

There is no global loaderFile, but CuteImage applies a per-component Cloudflare loader, cloudflareLoader, to Supabase storage URLs: its buildSrc carries rotation, crop and gravity as hash params, and the loader turns them into /cdn-cgi/image/ options. The built-in optimizer, and the host allow-list in images.remotePatterns (apps/web/next.config.ts), apply only to other next/image uses.

HelperFor
CuteImagenext/image for a stored image, with its rotation, crop and gravity
getOptimizedImageUrl()Building a Cloudflare URL, for raw <img> and srcset
getEffectiveDimensions()Swapping width and height for 90° and 270° rotations

Always query listingImages through withListingImages() (packages/db/query-helpers.ts) so index [0] is the cover.

Blur placeholders

Generated after submit, through Cloudflare: a 20px-wide WebP at quality 20, stored as a base64 data URI in lqip, plus a 1×1 resize for the dominant colour.

Where things live

ConcernWhere
Photo step + browser uploadfeatures/listing-form/components/1-add-photos/
Registration routefeatures/listing-form/api/upload-images.route.ts, which web's and cute-api's upload/images/route.ts re-export
Session statefeatures/listing-form/components/1-add-photos/hooks/use-listing-images.ts
Attachmentfeatures/listing-form/actions/helpers/activate-session-images.ts
Screeningpackages/ai/lib/vision-client.ts, features/listings/utils/prohibited-photo-terms.ts
LQIPpackages/lib/listings/services/generate-lqip.service.ts
Transform helperspackages/supabase/services/storage.ts, packages/ui-web/components/ui/cute-image.tsx, packages/ui-web/lib/cloudflare-loader.ts, features/listings/utils/image-dimensions.ts
AIfeatures/listings/services/photo-analysis/, features/listing-form/hooks/use-photo-analysis.ts
Schemapackages/db/schema/listing-images.ts, listing-image-analyses.ts

Editing an existing listing

The same path. The session hook seeds itself from the existing rows (isNew: false), and upload, reorder, rotate and crop behave identically. On submit, positions and transforms are rewritten for every image and the removed ones are retired.