add Printful mockup generator and post-sync angle enrichment

New PrintfulGenerator module creates demo products in Printful with
multi-variant support (multiple colours/sizes per product type).
Mix task gets --provider flag to choose between Printify and Printful.

After syncing Printful products, MockupEnricher Oban worker calls the
legacy mockup generator API to produce extra angle images (back, left,
right) and appends them as product images. Jobs are staggered 45s apart
with snooze-based 429 handling. Flat products (canvas, poster) get no
extras — apparel and 3D products get 1-5 extra angles each.

Also fixes:
- cross-provider slug uniqueness (appends -2, -3 suffix)
- category mapping order (Accessories before Canvas Prints)
- image dedup by URL instead of colour (fixes canvas variants)
- artwork URL stored in provider_data for enricher access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-15 16:52:53 +00:00
parent 61cb2b7a87
commit 1aceaf9444
7 changed files with 916 additions and 76 deletions

View File

@@ -172,6 +172,31 @@ defmodule SimpleshopTheme.Clients.Printful do
get("/store/products/#{product_id}")
end
@doc """
Create a sync product with variants and design files.
## Example
create_sync_product(%{
sync_product: %{name: "My T-Shirt"},
sync_variants: [%{
variant_id: 4011,
retail_price: "29.99",
files: [%{url: "https://example.com/design.png", type: "default"}]
}]
})
"""
def create_sync_product(product_data) do
post("/store/products", product_data)
end
@doc """
Delete a sync product and all its variants.
"""
def delete_sync_product(product_id) do
delete("/store/products/#{product_id}")
end
# =============================================================================
# Shipping (v2)
# =============================================================================
@@ -242,6 +267,28 @@ defmodule SimpleshopTheme.Clients.Printful do
get(path)
end
# =============================================================================
# Mockup generator (legacy, multi-angle)
# =============================================================================
@doc """
Create a mockup generator task for a catalog product.
Returns `{:ok, %{"task_key" => "gt-...", "status" => "pending"}}`.
"""
def create_mockup_generator_task(catalog_product_id, body) do
post("/mockup-generator/create-task/#{catalog_product_id}", body)
end
@doc """
Poll a mockup generator task by task key.
Returns `{:ok, %{"status" => "completed", "mockups" => [...]}}` when done.
"""
def get_mockup_generator_task(task_key) do
get("/mockup-generator/task?task_key=#{task_key}")
end
# =============================================================================
# Files (v2)
# =============================================================================

View File

@@ -0,0 +1,422 @@
defmodule SimpleshopTheme.Mockups.PrintfulGenerator do
@moduledoc """
Generates product mockups and/or creates demo products using the Printful API.
Two independent capabilities, composable via options:
1. **Mockup images** — uses the v2 mockup tasks API to generate on-product
mockup images for the theme preview. Images are saved to `priv/static/mockups/`.
2. **Sync products** — creates real products in the Printful store via the
v1 store products API. These can later be synced into the shop.
"""
alias SimpleshopTheme.Clients.Printful, as: Client
alias SimpleshopTheme.Images.Optimizer
alias SimpleshopTheme.Mockups.Generator
@output_dir "priv/static/mockups"
@poll_interval_ms 2_000
@max_poll_attempts 30
@api_delay_ms 3_000
@max_retries 3
# ============================================================================
# Catalog config
# ============================================================================
@doc """
Maps product types to Printful catalog product IDs, techniques, and variant IDs.
Variant IDs are needed for sync product creation. Catalog product IDs and
techniques are needed for mockup generation.
"""
def catalog_config do
%{
# Multiple variant IDs = multiple colour/size mockups per product
canvas: %{
catalog_product_id: 3,
technique: "digital",
variant_ids: [
{19303, "16″×24″"},
{19309, "20″×24″"},
{19315, "24″×30″"},
{825, "24″×36″"}
]
},
tshirt: %{
catalog_product_id: 71,
technique: "dtg",
variant_ids: [
{4017, "Black / M"},
{4027, "Ash / M"},
{4022, "Aqua / M"},
{4082, "Gold / M"},
{8452, "Forest / M"}
]
},
hoodie: %{
catalog_product_id: 146,
technique: "dtg",
variant_ids: [
{5531, "Black / M"},
{20553, "Ash / M"},
{21636, "Carolina Blue / M"},
{5555, "Dark Chocolate / M"}
]
},
tote: %{
catalog_product_id: 274,
technique: "cut-sew",
variant_ids: [
{9039, "Black"},
{9040, "Red"},
{9041, "Yellow"}
]
},
mug: %{
catalog_product_id: 19,
technique: "sublimation",
variant_ids: [{1320, "White / 11oz"}]
},
blanket: %{
catalog_product_id: 395,
technique: "sublimation",
variant_ids: [{13222, "60″×80″"}]
},
laptop_sleeve: %{
catalog_product_id: 394,
technique: "sublimation",
variant_ids: [{10984, "13″"}]
},
phone_case: %{
catalog_product_id: 181,
technique: "uv",
variant_ids: [{17616, "iPhone 15 Pro Max"}]
},
poster: %{
catalog_product_id: 1,
technique: "digital",
variant_ids: [
{3876, "12″×18″"},
{3877, "16″×20″"},
{1, "18″×24″"}
]
}
}
end
@doc """
Product definitions filtered to types available in Printful's catalog.
Reuses artwork URLs and slugs from the shared Printify generator definitions,
but skips product types not available in Printful (cushion, notebook).
"""
def product_definitions do
supported = Map.keys(catalog_config())
Generator.product_definitions()
|> Enum.filter(fn def -> def.product_type in supported end)
end
# ============================================================================
# Main entry point
# ============================================================================
@doc """
Generate mockups and/or create products.
## Options
* `:mockups` — generate mockup images (default: true)
* `:products` — create sync products in Printful (default: true)
* `:cleanup` — delete created sync products after (default: false)
"""
def generate_all(opts \\ []) do
do_mockups = Keyword.get(opts, :mockups, true)
do_products = Keyword.get(opts, :products, true)
cleanup = Keyword.get(opts, :cleanup, false)
definitions = product_definitions()
IO.puts("Starting Printful generation...")
IO.puts(" Mockups: #{do_mockups}, Products: #{do_products}, Cleanup: #{cleanup}")
IO.puts(" #{length(definitions)} product definitions")
IO.puts("")
# Generate mockup images
mockup_results =
if do_mockups do
generate_mockups(definitions)
else
[]
end
# Create sync products
product_results =
if do_products do
create_products(definitions)
else
[]
end
# Cleanup if requested
if cleanup and do_products do
IO.puts("")
IO.puts("Cleaning up created products...")
product_results
|> Enum.filter(&match?({:ok, _, _}, &1))
|> Enum.each(fn {:ok, slug, product_id} ->
IO.puts(" Deleting #{slug} (#{product_id})...")
Client.delete_sync_product(product_id)
Process.sleep(200)
end)
IO.puts("Cleanup complete.")
end
IO.puts("")
IO.puts("Printful generation complete!")
%{mockups: mockup_results, products: product_results}
end
# ============================================================================
# Mockup generation (v2 mockup tasks)
# ============================================================================
defp generate_mockups(definitions) do
IO.puts("Generating mockup images...")
IO.puts("")
definitions
|> Enum.with_index()
|> Enum.map(fn {product_def, index} ->
if index > 0, do: Process.sleep(@api_delay_ms)
IO.puts(" Mockup: #{product_def.name}")
case generate_single_mockup(product_def) do
{:ok, paths} ->
IO.puts("#{length(paths)} mockup(s)")
{:ok, product_def.slug, paths}
{:error, reason} ->
IO.puts("#{inspect(reason)}")
{:error, product_def.slug, reason}
end
end)
end
defp generate_single_mockup(product_def) do
config = catalog_config()[product_def.product_type]
variant_ids = Enum.map(config.variant_ids, fn {id, _label} -> id end)
task_body = %{
format: "jpg",
products: [
%{
source: "catalog",
catalog_product_id: config.catalog_product_id,
catalog_variant_ids: variant_ids,
placements: [
%{
placement: "front",
technique: config.technique,
layers: [
%{type: "file", url: product_def.artwork_url}
]
}
]
}
]
}
with {:ok, task_data} <- Client.create_mockup_task(task_body),
task_id <- extract_task_id(task_data),
{:ok, completed} <- poll_mockup_task(task_id),
mockup_urls <- extract_mockup_urls(completed) do
download_mockups(product_def.slug, mockup_urls)
end
end
defp extract_task_id(data) when is_list(data), do: hd(data)["id"]
defp extract_task_id(%{"id" => id}), do: id
defp extract_task_id(data), do: data["id"]
defp poll_mockup_task(task_id) do
poll_mockup_task(task_id, 0)
end
defp poll_mockup_task(_task_id, attempt) when attempt >= @max_poll_attempts do
{:error, :timeout}
end
defp poll_mockup_task(task_id, attempt) do
Process.sleep(@poll_interval_ms)
case Client.get_mockup_tasks(%{"id" => task_id}) do
{:ok, data} ->
task = find_task(data, task_id)
case task["status"] do
"completed" ->
{:ok, task}
"failed" ->
{:error, {:mockup_failed, task["failure_reasons"]}}
_pending ->
IO.puts(" Polling... (#{attempt + 1}/#{@max_poll_attempts})")
poll_mockup_task(task_id, attempt + 1)
end
{:error, reason} ->
{:error, reason}
end
end
defp find_task(data, task_id) when is_list(data) do
Enum.find(data, fn t -> t["id"] == task_id end) || hd(data)
end
defp find_task(data, _task_id) when is_map(data), do: data
defp extract_mockup_urls(task) do
(task["catalog_variant_mockups"] || [])
|> Enum.flat_map(fn cvm -> cvm["mockups"] || [] end)
|> Enum.map(fn m -> m["mockup_url"] end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
end
# ============================================================================
# Image download (shared logic with Printify generator)
# ============================================================================
defp download_mockups(product_slug, mockup_urls) do
File.mkdir_p!(@output_dir)
results =
mockup_urls
|> Enum.with_index(1)
|> Enum.map(fn {url, index} ->
basename = "#{product_slug}-#{index}"
source_path = Path.join(@output_dir, "#{basename}.webp")
IO.puts(" Processing mockup #{index}...")
temp_path = Path.join(System.tmp_dir!(), "#{basename}-pf-temp.jpg")
with {:ok, _} <- download_file(url, temp_path),
{:ok, image_data} <- File.read(temp_path),
{:ok, webp_data, source_width, _} <- Optimizer.to_optimized_webp(image_data),
:ok <- File.write(source_path, webp_data),
{:ok, _} <- Optimizer.process_file(webp_data, basename, @output_dir) do
File.rm(temp_path)
IO.puts(" Saved #{basename} (#{source_width}px)")
{:ok, basename, source_width}
else
{:error, reason} ->
File.rm(temp_path)
{:error, {url, reason}}
end
end)
successful = Enum.filter(results, &match?({:ok, _, _}, &1))
{:ok, Enum.map(successful, fn {:ok, basename, _} -> basename end)}
end
# Download without auth headers (mockup URLs are pre-signed S3 URLs)
defp download_file(url, output_path) do
case Req.get(url, into: File.stream!(output_path), receive_timeout: 60_000) do
{:ok, %Req.Response{status: status}} when status in 200..299 ->
{:ok, output_path}
{:ok, %Req.Response{status: status}} ->
File.rm(output_path)
{:error, {:http_error, status}}
{:error, reason} ->
File.rm(output_path)
{:error, reason}
end
end
# ============================================================================
# Sync product creation (v1 store products)
# ============================================================================
defp create_products(definitions) do
IO.puts("Creating sync products...")
IO.puts("")
definitions
|> Enum.with_index()
|> Enum.map(fn {product_def, index} ->
if index > 0, do: Process.sleep(@api_delay_ms)
IO.puts(" Product: #{product_def.name}")
case create_single_product(product_def) do
{:ok, product_id} ->
IO.puts(" ✓ Created (ID: #{product_id})")
{:ok, product_def.slug, product_id}
{:error, reason} ->
IO.puts("#{inspect(reason)}")
{:error, product_def.slug, reason}
end
end)
end
defp create_single_product(product_def) do
config = catalog_config()[product_def.product_type]
price =
product_def.price
|> Decimal.new()
|> Decimal.div(100)
|> Decimal.to_string()
sync_variants =
Enum.map(config.variant_ids, fn {variant_id, _label} ->
%{
variant_id: variant_id,
retail_price: price,
files: [%{url: product_def.artwork_url, type: "default"}]
}
end)
product_data = %{
sync_product: %{name: product_def.name},
sync_variants: sync_variants
}
with_retry(fn -> Client.create_sync_product(product_data) end)
|> case do
{:ok, result} -> {:ok, result["id"]}
{:error, reason} -> {:error, reason}
end
end
# ============================================================================
# Retry logic for rate limits
# ============================================================================
defp with_retry(fun, attempt \\ 1) do
case fun.() do
{:error, {429, _}} when attempt <= @max_retries ->
wait = attempt * 60_000
IO.puts(
" Rate limited, waiting #{div(wait, 1000)}s (retry #{attempt}/#{@max_retries})..."
)
Process.sleep(wait)
with_retry(fun, attempt + 1)
result ->
result
end
end
end

View File

@@ -352,9 +352,23 @@ defmodule SimpleshopTheme.Products do
error -> error
end
_ ->
# Not found or belongs to different connection - insert new
nil ->
# Not found at all - insert new
do_insert_product(attrs)
_different_connection ->
# Slug taken by a different provider connection - make it unique
unique_slug = make_unique_slug(slug)
do_insert_product(Map.put(attrs, :slug, unique_slug))
end
end
defp make_unique_slug(base_slug, suffix \\ 2) do
candidate = "#{base_slug}-#{suffix}"
case Repo.get_by(Product, slug: candidate) do
nil -> candidate
_ -> make_unique_slug(base_slug, suffix + 1)
end
end
@@ -421,6 +435,14 @@ defmodule SimpleshopTheme.Products do
Repo.get(ProductImage, id)
end
@doc """
Lists all images for a product, ordered by position.
"""
def list_product_images(product_id) do
from(i in ProductImage, where: i.product_id == ^product_id, order_by: i.position)
|> Repo.all()
end
@doc """
Links a product image to a Media.Image by setting its image_id.
"""

View File

@@ -314,6 +314,7 @@ defmodule SimpleshopTheme.Providers.Printful do
blueprint_id: catalog_product_id,
print_provider_id: 0,
thumbnail_url: sync_product["thumbnail_url"],
artwork_url: extract_artwork_url(sync_variants),
options: build_option_types(sync_variants),
raw: %{sync_product: sync_product}
}
@@ -345,7 +346,7 @@ defmodule SimpleshopTheme.Providers.Printful do
opts
end
# Extract unique preview images from sync variants (one per unique colour)
# Extract unique preview images from sync variants (one per unique image URL)
defp extract_preview_images(sync_variants) do
sync_variants
|> Enum.flat_map(fn sv ->
@@ -354,18 +355,28 @@ defmodule SimpleshopTheme.Providers.Printful do
|> Enum.map(fn file ->
%{
src: file["preview_url"] || file["thumbnail_url"],
color: sv["color"]
color: sv["color"],
name: sv["name"]
}
end)
end)
|> Enum.uniq_by(& &1.color)
|> Enum.uniq_by(& &1.src)
|> Enum.with_index()
|> Enum.map(fn {img, index} ->
%{
src: img.src,
position: index,
alt: img.color
}
alt = if img.color not in [nil, ""], do: img.color, else: img.name
%{src: img.src, position: index, alt: alt}
end)
end
# Find the artwork (design file) URL from the first variant's "default" file
defp extract_artwork_url(sync_variants) do
sync_variants
|> Enum.find_value(fn sv ->
(sv["files"] || [])
|> Enum.find_value(fn
%{"type" => "default", "url" => url} when is_binary(url) -> url
_ -> nil
end)
end)
end
@@ -423,10 +434,10 @@ defmodule SimpleshopTheme.Providers.Printful do
cond do
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
has_keyword?(name_lower, ~w[bag tote hat cap sleeve phone case]) -> "Accessories"
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion throw]) -> "Homewares"
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
true -> "Apparel"
end
end

View File

@@ -0,0 +1,254 @@
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

View File

@@ -21,6 +21,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Providers.Provider
alias SimpleshopTheme.Sync.ImageDownloadWorker
alias SimpleshopTheme.Sync.MockupEnricher
require Logger
@@ -96,6 +97,11 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
)
# Enqueue mockup enrichment for Printful products (extra angle images)
if conn.provider_type == "printful" do
enqueue_mockup_enrichment(conn, results)
end
# Sync shipping rates (non-fatal — logged and skipped on failure)
sync_shipping_rates(conn, provider, products)
@@ -228,4 +234,27 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
"Shipping rate sync crashed for #{conn.provider_type}: #{Exception.message(e)}"
)
end
# Enqueue MockupEnricher jobs for created/updated Printful products
defp enqueue_mockup_enrichment(conn, results) do
products_to_enrich =
results
|> Enum.filter(&match?({:ok, _, status} when status in [:created, :updated], &1))
|> Enum.map(fn {:ok, product, _status} -> product end)
if products_to_enrich != [] do
Logger.info(
"Enqueueing mockup enrichment for #{length(products_to_enrich)} Printful product(s)"
)
products_to_enrich
|> Enum.with_index()
|> Enum.each(fn {product, index} ->
MockupEnricher.enqueue(conn.id, product.id, index)
end)
end
rescue
e ->
Logger.error("Mockup enrichment enqueue failed: #{Exception.message(e)}")
end
end