A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.
What is here:
storefront/ the Next.js app
backend/ ONLY the custom Spree code. Spree itself is a prebuilt image
that mounts no app code, so every file here needs an explicit
volume entry in docker-compose.yml or it is silently ignored.
catalogue/ the scripts that loaded and maintain the products
docs/ manifest, mock registry, napkins
Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.
The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
337 lines
13 KiB
Ruby
337 lines
13 KiB
Ruby
require 'net/http'
|
|
require 'securerandom'
|
|
|
|
module Spree
|
|
module Admin
|
|
# Image Studio — the seller's control surface for the Photoroom pipeline.
|
|
#
|
|
# The real flow this exists for: photograph stock on a phone, open this page,
|
|
# upload the shots, see what they look like standardised, and post them to
|
|
# the store — either onto an existing product or as a brand new listing.
|
|
#
|
|
# Three steps, deliberately:
|
|
#
|
|
# show choose a source (uploaded photos, or an existing product's image)
|
|
# preview send each image to Photoroom, hold the results as UNATTACHED
|
|
# ActiveStorage blobs, show them beside the originals
|
|
# apply attach those blobs — to an existing product, or to one created here
|
|
#
|
|
# Nothing touches the catalogue until `apply`. Rejecting a batch is just
|
|
# navigating away: the blobs were never attached, so there is nothing to undo
|
|
# and no rejects left behind in a product gallery.
|
|
class ImageStudioController < BaseController
|
|
PHOTOROOM_EDIT = 'https://image-api.photoroom.com/v2/edit'.freeze
|
|
|
|
# Each image is a round trip of roughly 3-5 seconds, run one after another.
|
|
# Past this many the request starts flirting with the web server's timeout,
|
|
# so the cap is enforced rather than left to chance.
|
|
MAX_PHOTOS = 8
|
|
|
|
BACKGROUNDS = {
|
|
'White' => 'FFFFFF',
|
|
'Studio grey' => 'F2F2F2',
|
|
'Beige' => 'EDE6DA',
|
|
'Black' => '0E0D0B'
|
|
}.freeze
|
|
|
|
SIZES = ['1000x1000', '1500x1500', '2000x2000'].freeze
|
|
|
|
def show
|
|
load_form
|
|
end
|
|
|
|
def preview
|
|
load_form
|
|
|
|
key = photoroom_key
|
|
if key.blank?
|
|
# Loud, not a guess. A missing key must never quietly become a no-op
|
|
# that leaves the seller believing the photos were processed.
|
|
flash.now[:error] = 'PHOTOROOM_API_KEY is not set on the server. ' \
|
|
'Add it to .env and run `docker compose up -d`.'
|
|
return render :show, status: :unprocessable_entity
|
|
end
|
|
|
|
sources = collect_sources
|
|
if sources.empty?
|
|
flash.now[:error] = 'Choose some photos to upload, or pick a product to reprocess.'
|
|
return render :show, status: :unprocessable_entity
|
|
end
|
|
|
|
size = params[:size].presence_in(SIZES) || SIZES.first
|
|
background = BACKGROUNDS.values.include?(params[:background]) ? params[:background] : 'FFFFFF'
|
|
|
|
@results = []
|
|
sources.each do |src|
|
|
begin
|
|
png = photoroom_edit(src[:bytes], src[:filename], key, size, background)
|
|
rescue StandardError => e
|
|
@results << { filename: src[:filename], error: e.message }
|
|
next
|
|
end
|
|
|
|
after_blob = ActiveStorage::Blob.create_and_upload!(
|
|
io: StringIO.new(png),
|
|
filename: "#{File.basename(src[:filename], '.*')}-photoroom.png",
|
|
content_type: 'image/png'
|
|
)
|
|
|
|
@results << {
|
|
filename: src[:filename],
|
|
before_url: blob_path(src[:before_blob]),
|
|
after_url: blob_path(after_blob),
|
|
signed_id: after_blob.signed_id,
|
|
before_bytes: src[:bytes].bytesize,
|
|
after_bytes: png.bytesize
|
|
}
|
|
end
|
|
|
|
@processed = @results.count { |r| r[:error].blank? }
|
|
@failed = @results.count { |r| r[:error].present? }
|
|
@size = size
|
|
|
|
if @processed.zero?
|
|
flash.now[:error] = 'Every image failed. Nothing to apply.'
|
|
return render :show, status: :unprocessable_entity
|
|
end
|
|
|
|
render :show
|
|
end
|
|
|
|
def apply
|
|
signed_ids = Array(params[:signed_ids]).reject(&:blank?)
|
|
if signed_ids.empty?
|
|
return redirect_to(spree.admin_image_studio_path, alert: 'Nothing to apply.')
|
|
end
|
|
|
|
product =
|
|
if params[:destination] == 'new'
|
|
build_new_product
|
|
else
|
|
current_store.products.find_by!(slug: params[:target_slug])
|
|
end
|
|
|
|
return redirect_to(spree.admin_image_studio_path, alert: @create_error) if @create_error
|
|
|
|
make_primary = params[:make_primary] == '1'
|
|
position = make_primary ? 1 : next_position(product)
|
|
attached = 0
|
|
|
|
signed_ids.each_with_index do |signed_id, i|
|
|
asset = product.media.build(position: position + i)
|
|
asset.type = 'Spree::Image'
|
|
asset.attachment.attach(signed_id)
|
|
attached += 1 if asset.save
|
|
end
|
|
|
|
if attached.zero?
|
|
return redirect_to(spree.admin_image_studio_path, alert: 'No images could be attached.')
|
|
end
|
|
|
|
notice = "#{attached} image#{'s' if attached != 1} added to #{product.name}"
|
|
notice += ' — created as a draft, publish it once the details are filled in' if @created_draft
|
|
if @visibility_warning
|
|
return redirect_to spree.admin_product_path(product),
|
|
alert: "#{notice}, #{@visibility_warning}."
|
|
end
|
|
redirect_to spree.admin_product_path(product), notice: notice
|
|
end
|
|
|
|
private
|
|
|
|
def load_form
|
|
@breadcrumb_icon = 'photo'
|
|
add_breadcrumb 'Image Studio', spree.admin_image_studio_path
|
|
@products = current_store.products.where(deleted_at: nil).order(:name)
|
|
@backgrounds = BACKGROUNDS
|
|
@sizes = SIZES
|
|
@currency = current_store.default_currency
|
|
@max_photos = MAX_PHOTOS
|
|
end
|
|
|
|
# Two ways in: files straight off the seller's device, or an image already
|
|
# on a product (useful for re-cutting an old listing).
|
|
def collect_sources
|
|
uploads = Array(params[:photos]).reject(&:blank?).first(MAX_PHOTOS)
|
|
|
|
if uploads.any?
|
|
return uploads.map do |file|
|
|
bytes = file.read
|
|
blob = ActiveStorage::Blob.create_and_upload!(
|
|
io: StringIO.new(bytes),
|
|
filename: file.original_filename,
|
|
content_type: file.content_type.presence || 'image/jpeg'
|
|
)
|
|
{ bytes: bytes, filename: file.original_filename, before_blob: blob }
|
|
end
|
|
end
|
|
|
|
return [] if params[:slug].blank?
|
|
|
|
product = current_store.products.find_by(slug: params[:slug])
|
|
asset = product && primary_asset(product)
|
|
return [] if asset.nil?
|
|
|
|
@product = product
|
|
[{
|
|
bytes: asset.attachment.download,
|
|
filename: asset.attachment.filename.to_s,
|
|
before_blob: asset.attachment.blob
|
|
}]
|
|
end
|
|
|
|
def build_new_product
|
|
name = params[:new_name].to_s.strip
|
|
if name.blank?
|
|
@create_error = 'A new product needs a name.'
|
|
return nil
|
|
end
|
|
|
|
price = params[:new_price].to_s.strip
|
|
if price.blank? || price.to_f <= 0
|
|
# A product with no price row in the store currency is excluded from
|
|
# the storefront with no error anywhere. Refuse rather than create one.
|
|
@create_error = "A new product needs a price in #{current_store.default_currency}."
|
|
return nil
|
|
end
|
|
|
|
publish = params[:publish] == '1'
|
|
@created_draft = !publish
|
|
|
|
# A product belongs to ONE store in Spree 5.6 (`belongs_to :store`).
|
|
# `product.stores` is a Relation, not a collection to push onto —
|
|
# `stores << store` raises NoMethodError.
|
|
product = Spree::Product.new(
|
|
name: name,
|
|
store: current_store,
|
|
shipping_category_id: Spree::ShippingCategory.first&.id,
|
|
status: publish ? 'active' : 'draft',
|
|
# beginning_of_minute, not Time.current: the storefront scope compares
|
|
# against cutoff.beginning_of_minute, which truncates DOWN, so a
|
|
# timestamp from seconds ago reads as the future and the product stays
|
|
# invisible until the minute ticks over.
|
|
available_on: publish ? Time.current.beginning_of_minute : nil
|
|
)
|
|
product.save!
|
|
|
|
# Set the price explicitly against the STORE currency. Spree's generic
|
|
# price setter uses the global currency preference, which is not the same
|
|
# thing — that mismatch is what makes products invisible on the storefront.
|
|
product.master.prices
|
|
.find_or_initialize_by(currency: current_store.default_currency)
|
|
.update!(amount: price.to_f)
|
|
|
|
# Stock, or the storefront will never show it. `available` requires the
|
|
# product to be purchasable, and a master with no stock item is not:
|
|
# in_stock? false -> purchasable? false -> excluded from the storefront
|
|
# with no error anywhere. Matching how the existing catalogue is set up:
|
|
# inventory untracked (on_hand Infinity) with a stock item per location.
|
|
master = product.master
|
|
master.update!(track_inventory: false)
|
|
Spree::StockLocation.where(active: true).find_each do |location|
|
|
master.stock_items.find_or_create_by!(stock_location_id: location.id)
|
|
end
|
|
|
|
# Publish to the channel. Visibility is per-channel via
|
|
# Spree::ProductPublication, NOT the product's `status` alone — a product
|
|
# with no publication row is dropped by the storefront's join, no matter
|
|
# how active and priced and stocked it is.
|
|
if publish
|
|
channel = Spree::Current.channel ||
|
|
Spree::Channel.find_by(name: 'Online Store') ||
|
|
Spree::Channel.order(:id).first
|
|
if channel
|
|
Spree::ProductPublication
|
|
.find_or_create_by!(product_id: product.id, channel_id: channel.id) do |pub|
|
|
pub.published_at = Time.current.beginning_of_minute
|
|
end
|
|
end
|
|
end
|
|
|
|
# Check rather than assume. This is the exact failure that hid 130
|
|
# products for an hour: every write returned success and the storefront
|
|
# showed nothing.
|
|
unless current_store.products
|
|
.available(Time.current, current_store.default_currency)
|
|
.where(id: product.id).exists?
|
|
# A warning, not an error: the product exists now, so bailing out here
|
|
# would leave it behind with no images at all. Attach them, then say so.
|
|
@visibility_warning = 'but it is NOT visible on the storefront yet — ' \
|
|
'check its price, stock and status'
|
|
end
|
|
|
|
product
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
@create_error = "Could not create product: #{e.record.errors.full_messages.to_sentence}"
|
|
nil
|
|
end
|
|
|
|
# A product's gallery is the UNION of product-level `media` and any legacy
|
|
# assets pinned to the master variant, so the next free slot has to clear
|
|
# both. Taking max(:position) from `media` alone put an appended image at
|
|
# position 2, in the middle of the gallery, instead of at the end.
|
|
def next_position(product)
|
|
product_max = product.media.maximum(:position).to_i
|
|
master_max = product.master&.images&.maximum(:position).to_i || 0
|
|
[product_max, master_max].max + 1
|
|
end
|
|
|
|
def primary_asset(product)
|
|
product.media.order(:position).first ||
|
|
product.master&.images&.order(:position)&.first
|
|
end
|
|
|
|
# url_for on an attachment resolves to attachment_url, which does not exist
|
|
# on a Spree engine controller. Go through the ActiveStorage helpers.
|
|
def blob_path(blob_or_attachment)
|
|
Rails.application.routes.url_helpers.rails_blob_path(blob_or_attachment, only_path: true)
|
|
end
|
|
|
|
def photoroom_key
|
|
ENV['PHOTOROOM_API_KEY']
|
|
end
|
|
|
|
def photoroom_edit(image_bytes, filename, key, size, background)
|
|
boundary = SecureRandom.hex(16)
|
|
fields = {
|
|
'background.color' => background,
|
|
'outputSize' => size,
|
|
'padding' => '0.05'
|
|
}
|
|
|
|
body = +''
|
|
fields.each do |k, v|
|
|
body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\"\r\n\r\n#{v}\r\n"
|
|
end
|
|
body << "--#{boundary}\r\nContent-Disposition: form-data; " \
|
|
"name=\"imageFile\"; filename=\"#{filename}\"\r\n" \
|
|
"Content-Type: image/jpeg\r\n\r\n"
|
|
body = body.b + image_bytes.b + "\r\n--#{boundary}--\r\n".b
|
|
|
|
uri = URI(PHOTOROOM_EDIT)
|
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
http.use_ssl = true
|
|
http.read_timeout = 180
|
|
|
|
req = Net::HTTP::Post.new(uri)
|
|
req['x-api-key'] = key
|
|
req['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
|
req.body = body
|
|
|
|
res = http.request(req)
|
|
|
|
raise "HTTP #{res.code}: #{res.body.to_s[0, 200]}" unless res.is_a?(Net::HTTPSuccess)
|
|
|
|
# A 200 is not proof. Photoroom answers 200 with an empty body on some
|
|
# failures, and an empty attachment saves cleanly and serves nothing.
|
|
raise 'returned 200 with an empty body' if res.body.nil? || res.body.empty?
|
|
unless res['content-type'].to_s.start_with?('image/')
|
|
raise "returned #{res['content-type']} instead of an image"
|
|
end
|
|
|
|
res.body
|
|
end
|
|
end
|
|
end
|
|
end
|