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 migrationscripts/postgres/migrations/069_product_image_gallery.sql, idempotent and mirrored as boot statements ininternal/migrate/migrate.go):shop_item_id→shop_itemsON DELETE CASCADE,image_url,alt_text,sort_order.UNIQUE (shop_item_id, image_url)so a retried admin save cannot duplicate an image;sort_orderis dense and zero-based, renumbered server-side on every write rather than trusted from the request.image_urlstays, 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 whateverimage_urlthe client sent, so the two representations can never disagree about which image is the cover. - A write that omits
imagesentirely leaves the stored gallery alone and updates onlyimage_url— the legacy single-image client path.[](present but empty) clears the gallery. Go'sencoding/jsondistinguishes an absent key (nilslice) 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/Caddyfilewas already sized for exactly this.internal/storage.MediaStoreis a separate type from the KYCdocstore.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 cachedimmutableforever. - The type is sniffed, never claimed. The extension and served
Content-Typeboth come from the bytes, so a.pngthat 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 addsnosniffon 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_DIRdefaults inside the existing/opt/aim-store/staticmount, whichbootstrap.shalready chowns to the API's uid.
Consequences¶
GET /api/v1/productsand/products/:idgain an additiveimages: []field. It is always present and nevernull, 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 toimage_urlwhenimagesis 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_imagestill 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/:keyor a third-party CDN. - The commerce portion of
schema.sqlchanged, soTestSchemaSQLPublicPortionFrozen'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_itemsitself is untouched.