defmodule SimpleshopTheme.Sync.MockupEnricher do @moduledoc """ Oban worker that enriches Printful products with extra mockup angle images. After product sync, Printful products only have front-view preview images. This worker uses the legacy mockup generator API to produce extra angles (back, left, right, etc.) and appends them as additional product images. Each product is processed as a separate job so failures don't block others. The temporary S3 URLs from the mockup generator are downloaded via the existing ImageDownloadWorker pipeline. """ use Oban.Worker, queue: :images, max_attempts: 5 alias SimpleshopTheme.Clients.Printful, as: Client alias SimpleshopTheme.Products alias SimpleshopTheme.Products.ProviderConnection alias SimpleshopTheme.Sync.ImageDownloadWorker require Logger @poll_interval_ms 3_000 @max_poll_attempts 20 # Mockup generator config per catalog product type: # {placement, area_width, area_height} # Apparel/accessories use "front", flat products use "default" @product_configs %{ 1 => {"default", 4500, 6750}, 3 => {"default", 4800, 7200}, 19 => {"default", 3150, 1350}, 71 => {"front", 4500, 5100}, 146 => {"front", 4500, 4500}, 181 => {"default", 1092, 2286}, 274 => {"default", 3300, 3300}, 394 => {"default", 3900, 2925}, 395 => {"default", 9000, 10800} } # Stagger jobs to avoid hammering Printful's rate limits @job_stagger_seconds 45 @doc """ Enqueue mockup enrichment for a product. Accepts an optional `delay_index` to stagger jobs (each index adds #{@job_stagger_seconds}s of delay). """ def enqueue(conn_id, product_id, delay_index \\ 0) do delay = delay_index * @job_stagger_seconds scheduled_at = DateTime.add(DateTime.utc_now(), delay, :second) %{provider_connection_id: conn_id, product_id: product_id} |> new(scheduled_at: scheduled_at) |> Oban.insert() end @impl Oban.Worker def perform(%Oban.Job{ args: %{"provider_connection_id" => conn_id, "product_id" => product_id} }) do with %ProviderConnection{} = conn <- Products.get_provider_connection(conn_id), product when not is_nil(product) <- Products.get_product(product_id), :ok <- set_credentials(conn) do enrich_product(product) else nil -> {:cancel, :not_found} {:error, _} = error -> error end end defp enrich_product(product) do provider_data = product.provider_data || %{} catalog_product_id = provider_data["catalog_product_id"] || provider_data[:catalog_product_id] artwork_url = provider_data["artwork_url"] || provider_data[:artwork_url] catalog_variant_ids = provider_data["catalog_variant_ids"] || provider_data[:catalog_variant_ids] || [] cond do is_nil(catalog_product_id) -> Logger.info("[MockupEnricher] No catalog_product_id for #{product.title}, skipping") :ok is_nil(artwork_url) -> Logger.info("[MockupEnricher] No artwork_url for #{product.title}, skipping") :ok already_enriched?(product) -> Logger.info("[MockupEnricher] Already enriched #{product.title}, skipping") :ok true -> # Pick one representative variant for mockup generation variant_id = List.first(catalog_variant_ids) case generate_and_append(product, catalog_product_id, variant_id, artwork_url) do {:ok, count} -> Logger.info("[MockupEnricher] Added #{count} extra angle(s) for #{product.title}") :ok {:error, {429, _}} -> Logger.info("[MockupEnricher] Rate limited for #{product.title}, snoozing 60s") {:snooze, 60} {:error, {400, _} = reason} -> # Permanent API error (wrong placement, unsupported product, etc.) Logger.info("[MockupEnricher] #{product.title} not supported: #{inspect(reason)}") :ok {:error, reason} -> Logger.warning("[MockupEnricher] Failed for #{product.title}: #{inspect(reason)}") {:error, reason} end end end defp generate_and_append(product, catalog_product_id, variant_id, artwork_url) do {placement, area_width, area_height} = Map.get(@product_configs, catalog_product_id, {"front", 4500, 5100}) body = %{ variant_ids: if(variant_id, do: [variant_id], else: []), format: "jpg", files: [ %{ placement: placement, image_url: artwork_url, position: %{ area_width: area_width, area_height: area_height, width: area_width, height: area_height, top: 0, left: 0 } } ] } with {:ok, task_data} <- Client.create_mockup_generator_task(catalog_product_id, body), task_key <- task_data["task_key"], {:ok, result} <- poll_generator_task(task_key), extra_images <- extract_extra_images(result) do if extra_images == [] do {:ok, 0} else append_images_to_product(product, extra_images) end end end defp poll_generator_task(task_key), do: poll_generator_task(task_key, 0) defp poll_generator_task(_task_key, attempt) when attempt >= @max_poll_attempts do {:error, :timeout} end defp poll_generator_task(task_key, attempt) do Process.sleep(@poll_interval_ms) case Client.get_mockup_generator_task(task_key) do {:ok, %{"status" => "completed"} = result} -> {:ok, result} {:ok, %{"status" => "error", "error" => error}} -> {:error, {:mockup_error, error}} {:ok, %{"status" => _pending}} -> poll_generator_task(task_key, attempt + 1) {:error, {429, _}} -> # Rate limited — back off and retry Process.sleep(60_000) poll_generator_task(task_key, attempt + 1) {:error, reason} -> {:error, reason} end end # Collect all extra angle URLs from the mockup generator response. # The "extra" array contains alternate views (back, left, right, etc.) defp extract_extra_images(result) do (result["mockups"] || []) |> Enum.flat_map(fn mockup -> (mockup["extra"] || []) |> Enum.map(fn extra -> %{ src: extra["url"], alt: extra["title"] } end) end) |> Enum.reject(&is_nil(&1.src)) |> Enum.uniq_by(& &1.src) end defp append_images_to_product(product, extra_images) do # Find the current max position so we append after existing images existing_images = Products.list_product_images(product.id) max_position = existing_images |> Enum.map(& &1.position) |> Enum.max(fn -> -1 end) results = extra_images |> Enum.with_index(max_position + 1) |> Enum.map(fn {img, position} -> attrs = %{ product_id: product.id, src: img.src, alt: img.alt, position: position } case Products.create_product_image(attrs) do {:ok, product_image} -> ImageDownloadWorker.enqueue(product_image.id) {:ok, product_image} {:error, reason} -> Logger.warning("[MockupEnricher] Failed to create image: #{inspect(reason)}") {:error, reason} end end) count = Enum.count(results, &match?({:ok, _}, &1)) {:ok, count} end # Check if this product already has extra angle images from a prior enrichment defp already_enriched?(product) do images = Products.list_product_images(product.id) Enum.any?(images, fn img -> img.alt in ["Front", "Back", "Left", "Right"] end) end defp set_credentials(conn) do case ProviderConnection.get_api_key(conn) do api_key when is_binary(api_key) -> Process.put(:printful_api_key, api_key) store_id = get_in(conn.config, ["store_id"]) if store_id, do: Process.put(:printful_store_id, store_id) :ok nil -> {:error, :no_api_key} end end end