Skip to content

0013 — Multiple images per product

Status: accepted (2026-08-18).

Context

shop_items carried exactly one image_url. That single column is read in more places than the product page: the public /api/v1/products list, the website and app product cards, cart line items, orders.product_image snapshots, and the admin table thumbnail. A storefront selling physical accessories needs several photos per product — colourways, packaging, the ports on a cable — and one column cannot express that.

The obvious shapes were a TEXT[]/JSONB column on shop_items, or a child table. The catalogue seed that motivated this (eight Hapipola accessories: charging cables, an adaptor, a smartwatch and three TWS earbuds) carries up to eight images each, with per-image alt text.

Decision

Add a child table, and keep shop_items.image_url as the denormalized cover.

  • shop_item_images (tracked migration scripts/postgres/migrations/069_product_image_gallery.sql, idempotent and mirrored as boot statements in internal/migrate/migrate.go): shop_item_idshop_items ON DELETE CASCADE, image_url, alt_text, sort_order. UNIQUE (shop_item_id, image_url) so a retried admin save cannot duplicate an image; sort_order is dense and zero-based, renumbered server-side on every write rather than trusted from the request.
  • image_url stays, and stays authoritative for "the one image" — it is not deprecated and not dropped. Every existing single-image consumer keeps working with no change at all, which is the entire reason for preferring a child table over replacing the column: the migration has no read-side flag day. The migration backfills a one-row gallery for every product that already had a cover, so no product reports an empty gallery while visibly having an image.
  • One invariant ties them together: when a write supplies a gallery, the cover becomes images[0]. The repository forces this (resolveWriteGallery), ignoring whatever image_url the client sent, so the two representations can never disagree about which image is the cover.
  • A write that omits images entirely leaves the stored gallery alone and updates only image_url — the legacy single-image client path. [] (present but empty) clears the gallery. Go's encoding/json distinguishes an absent key (nil slice) from [] (empty non-nil slice) for free, so this needs no sentinel value.
  • Reads are batched: the list endpoints load every gallery for the page in one extra query, not one per product — 2 queries for N products, not N+1.
  • Gallery size is capped at 12 images per product. Not a business rule: a guard so one malformed request cannot insert unbounded rows.

Why not an array column: alt text per image would need a parallel array or a composite type, ordering edits become read-modify-write on the whole shop_items row (contending with stock decrements at checkout), and the uniqueness guard would have to move into application code.

Addendum — uploading, not just linking

The first cut of the admin editor took image URLs only, which works when the photos already live somewhere (the seeded catalogue points at a supplier CDN) and not at all when someone has a file on their laptop. Uploading is now offered alongside the URL field rather than replacing it — re-hosting an image that is already served fine elsewhere is pure cost.

  • POST /api/v1/admin/uploads/image (admin-guarded) takes base64 JSON, not multipart: the admin browser reaches the API through the portal's Next.js proxy, which reads the request body as text to attach the admin token, and binary would not survive that. deploy/Caddyfile was already sized for exactly this.
  • internal/storage.MediaStore is a separate type from the KYC docstore.Store, not another implementation of it. Their security requirements are opposites — one must never have a public URL, the other exists to have one — and a shared interface would make serving a KYC document publicly a one-line mistake.
  • Content-addressed: the stored name is sha256(bytes) + ext. The client's filename is accepted and ignored, so it has nothing to traverse or overwrite; re-uploading the same file is idempotent; and the URL can be cached immutable forever.
  • The type is sniffed, never claimed. The extension and served Content-Type both come from the bytes, so a .png that is actually HTML cannot be served back as HTML from the API origin. SVG is refused outright: it is XML that can carry <script>, and nothing in a product catalogue needs it. Serving adds nosniff on top.
  • Fails closed: no media directory configured → 503 uploads_disabled, and the admin keeps working with URLs. A media-store failure never blocks the API boot, because the storefront must not go down over a photo upload.
  • No new deploy surface: MEDIA_DIR defaults inside the existing /opt/aim-store/static mount, which bootstrap.sh already chowns to the API's uid.

Consequences

  • GET /api/v1/products and /products/:id gain an additive images: [] field. It is always present and never null, so the three frontends have one shape to handle rather than two.
  • Old app and website builds keep rendering: they read image_url, which still means what it always meant. New builds fall back to image_url when images is missing, so a new client against an old backend also works.
  • The admin product dialog edits an ordered list — add, reorder, remove, per-image alt text — and the first row is labelled as the cover, because reordering silently changes what the whole store shows for that product.
  • orders.product_image still snapshots a single image. Order history is a record of what was bought, not a live gallery; nothing here changes it.
  • Uploading is an admin convenience, not a new storage dependency for the storefront: the app and website read an image URL and neither knows nor cares whether it points at /media/:key or a third-party CDN.
  • The commerce portion of schema.sql changed, so TestSchemaSQLPublicPortionFrozen's golden was deliberately regenerated — the second authorized commerce-schema change since that freeze was introduced (after 049's cost columns), and purely additive: shop_items itself is untouched.