feat: add admin provider setup UI with improved product sync
- Add /admin/providers LiveView for connecting and managing POD providers - Implement pagination for Printify API (handles all products, not just first page) - Add parallel processing (5 concurrent) for faster product sync - Add slug-based fallback matching when provider_product_id changes - Add error recovery with try/rescue to prevent stuck sync status - Add checksum-based change detection to skip unchanged products - Add upsert tests covering race conditions and slug matching - Add Printify provider tests - Document Printify integration research (product identity, order risks, open source vs managed hosting implications) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,9 +154,10 @@ defmodule SimpleshopTheme.Clients.Printify do
|
||||
|
||||
@doc """
|
||||
List all products in a shop.
|
||||
Printify allows a maximum of 50 products per page.
|
||||
"""
|
||||
def list_products(shop_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
limit = Keyword.get(opts, :limit, 50)
|
||||
page = Keyword.get(opts, :page, 1)
|
||||
get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
|
||||
end
|
||||
|
||||
@@ -28,6 +28,13 @@ defmodule SimpleshopTheme.Products do
|
||||
Repo.get(ProviderConnection, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single provider connection, raising if not found.
|
||||
"""
|
||||
def get_provider_connection!(id) do
|
||||
Repo.get!(ProviderConnection, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a provider connection by type.
|
||||
"""
|
||||
@@ -72,6 +79,24 @@ defmodule SimpleshopTheme.Products do
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of products for a provider connection.
|
||||
"""
|
||||
def count_products_for_connection(nil), do: 0
|
||||
|
||||
def count_products_for_connection(connection_id) do
|
||||
from(p in Product, where: p.provider_connection_id == ^connection_id, select: count())
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enqueues a product sync job for the given provider connection.
|
||||
Returns `{:ok, job}` or `{:error, changeset}`.
|
||||
"""
|
||||
def enqueue_sync(%ProviderConnection{} = conn) do
|
||||
SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Products
|
||||
# =============================================================================
|
||||
@@ -160,16 +185,19 @@ defmodule SimpleshopTheme.Products do
|
||||
def upsert_product(%ProviderConnection{id: conn_id}, attrs) do
|
||||
provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"]
|
||||
new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"])
|
||||
attrs = Map.put(attrs, :checksum, new_checksum)
|
||||
title = attrs[:title] || attrs["title"]
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.put(:checksum, new_checksum)
|
||||
|> Map.put(:provider_connection_id, conn_id)
|
||||
|
||||
# First check by provider_product_id
|
||||
case get_product_by_provider(conn_id, provider_product_id) do
|
||||
nil ->
|
||||
attrs = Map.put(attrs, :provider_connection_id, conn_id)
|
||||
|
||||
case create_product(attrs) do
|
||||
{:ok, product} -> {:ok, product, :created}
|
||||
error -> error
|
||||
end
|
||||
# Not found by provider ID - check by slug (same title = same product)
|
||||
slug = Slug.slugify(title)
|
||||
find_by_slug_or_insert(conn_id, slug, attrs, new_checksum)
|
||||
|
||||
%Product{checksum: ^new_checksum} = product ->
|
||||
{:ok, product, :unchanged}
|
||||
@@ -182,6 +210,77 @@ defmodule SimpleshopTheme.Products do
|
||||
end
|
||||
end
|
||||
|
||||
# If product exists with same slug, update it (including new provider_product_id)
|
||||
# Otherwise insert new product
|
||||
defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
|
||||
case get_product_by_slug(slug) do
|
||||
%Product{provider_connection_id: ^conn_id, checksum: ^new_checksum} = product ->
|
||||
# Same product, same checksum - just update the provider_product_id if changed
|
||||
if product.provider_product_id != attrs[:provider_product_id] do
|
||||
case update_product(product, %{provider_product_id: attrs[:provider_product_id]}) do
|
||||
{:ok, product} -> {:ok, product, :updated}
|
||||
error -> error
|
||||
end
|
||||
else
|
||||
{:ok, product, :unchanged}
|
||||
end
|
||||
|
||||
%Product{provider_connection_id: ^conn_id} = product ->
|
||||
# Same product, different checksum - full update including new provider_product_id
|
||||
case update_product(product, attrs) do
|
||||
{:ok, product} -> {:ok, product, :updated}
|
||||
error -> error
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Not found or belongs to different connection - insert new
|
||||
do_insert_product(attrs)
|
||||
end
|
||||
end
|
||||
|
||||
# Insert with conflict handling for race conditions
|
||||
defp do_insert_product(attrs) do
|
||||
case create_product(attrs) do
|
||||
{:ok, product} ->
|
||||
{:ok, product, :created}
|
||||
|
||||
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
|
||||
# Check if it's a unique constraint violation (race condition)
|
||||
if has_unique_constraint_error?(errors) do
|
||||
handle_insert_conflict(attrs, changeset)
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_insert_conflict(attrs, changeset) do
|
||||
conn_id = attrs[:provider_connection_id]
|
||||
provider_product_id = attrs[:provider_product_id]
|
||||
new_checksum = attrs[:checksum]
|
||||
|
||||
case get_product_by_provider(conn_id, provider_product_id) do
|
||||
nil ->
|
||||
{:error, changeset}
|
||||
|
||||
%Product{checksum: ^new_checksum} = product ->
|
||||
{:ok, product, :unchanged}
|
||||
|
||||
product ->
|
||||
case update_product(product, attrs) do
|
||||
{:ok, product} -> {:ok, product, :updated}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp has_unique_constraint_error?(errors) do
|
||||
Enum.any?(errors, fn
|
||||
{_field, {_msg, [constraint: :unique, constraint_name: _]}} -> true
|
||||
_ -> false
|
||||
end)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Product Images
|
||||
# =============================================================================
|
||||
|
||||
@@ -80,8 +80,7 @@ defmodule SimpleshopTheme.Products.Product do
|
||||
title = get_change(changeset, :title) || get_field(changeset, :title)
|
||||
|
||||
if title do
|
||||
slug = Slug.slugify(title)
|
||||
put_change(changeset, :slug, slug)
|
||||
put_change(changeset, :slug, Slug.slugify(title))
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
27
lib/simpleshop_theme/providers.ex
Normal file
27
lib/simpleshop_theme/providers.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule SimpleshopTheme.Providers do
|
||||
@moduledoc """
|
||||
Convenience functions for working with POD providers.
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Products.ProviderConnection
|
||||
alias SimpleshopTheme.Providers.Provider
|
||||
|
||||
@doc """
|
||||
Tests a provider connection.
|
||||
|
||||
Returns `{:ok, info}` with provider-specific info (e.g., shop name, shop_id)
|
||||
or `{:error, reason}` if the connection fails.
|
||||
"""
|
||||
def test_connection(%ProviderConnection{} = conn) do
|
||||
case Provider.for_connection(conn) do
|
||||
{:ok, provider} -> provider.test_connection(conn)
|
||||
{:error, :not_implemented} -> {:error, :provider_not_implemented}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a given type.
|
||||
"""
|
||||
defdelegate for_type(type), to: Provider
|
||||
end
|
||||
@@ -41,11 +41,7 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
else
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_api_key(api_key),
|
||||
{:ok, response} <- Client.list_products(shop_id) do
|
||||
products =
|
||||
response["data"]
|
||||
|> Enum.map(&normalize_product/1)
|
||||
|
||||
{:ok, products} <- fetch_all_products(shop_id) do
|
||||
{:ok, products}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
@@ -54,6 +50,33 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches all products by paginating through the API
|
||||
defp fetch_all_products(shop_id) do
|
||||
fetch_products_page(shop_id, 1, [])
|
||||
end
|
||||
|
||||
defp fetch_products_page(shop_id, page, acc) do
|
||||
case Client.list_products(shop_id, page: page) do
|
||||
{:ok, response} ->
|
||||
products = Enum.map(response["data"] || [], &normalize_product/1)
|
||||
all_products = acc ++ products
|
||||
|
||||
current_page = response["current_page"] || page
|
||||
last_page = response["last_page"] || 1
|
||||
|
||||
if current_page < last_page do
|
||||
# Small delay to be nice to rate limits (600/min = 10/sec)
|
||||
Process.sleep(100)
|
||||
fetch_products_page(shop_id, page + 1, all_products)
|
||||
else
|
||||
{:ok, all_products}
|
||||
end
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def submit_order(%ProviderConnection{config: config} = conn, order) do
|
||||
shop_id = config["shop_id"]
|
||||
|
||||
@@ -59,19 +59,34 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
# Private
|
||||
# =============================================================================
|
||||
|
||||
# Number of concurrent product syncs (DB operations only, not API calls)
|
||||
@max_concurrency 5
|
||||
|
||||
defp sync_products(conn) do
|
||||
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
|
||||
|
||||
Products.update_sync_status(conn, "syncing")
|
||||
|
||||
try do
|
||||
do_sync_products(conn)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Product sync crashed for #{conn.provider_type}: #{Exception.message(e)}")
|
||||
Products.update_sync_status(conn, "failed")
|
||||
{:error, :sync_crashed}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_sync_products(conn) do
|
||||
with {:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, products} <- provider.fetch_products(conn) do
|
||||
Logger.info("Fetched #{length(products)} products from #{conn.provider_type}")
|
||||
|
||||
results = sync_all_products(conn, products)
|
||||
|
||||
created = Enum.count(results, fn {_, _, status} -> status == :created end)
|
||||
updated = Enum.count(results, fn {_, _, status} -> status == :updated end)
|
||||
unchanged = Enum.count(results, fn {_, _, status} -> status == :unchanged end)
|
||||
errors = Enum.count(results, fn result -> match?({:error, _}, result) end)
|
||||
created = Enum.count(results, &match?({:ok, _, :created}, &1))
|
||||
updated = Enum.count(results, &match?({:ok, _, :updated}, &1))
|
||||
unchanged = Enum.count(results, &match?({:ok, _, :unchanged}, &1))
|
||||
errors = Enum.count(results, &match?({:error, _}, &1))
|
||||
|
||||
Logger.info(
|
||||
"Product sync complete for #{conn.provider_type}: " <>
|
||||
@@ -89,18 +104,35 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
end
|
||||
|
||||
defp sync_all_products(conn, products) do
|
||||
Enum.map(products, fn product_data ->
|
||||
case sync_product(conn, product_data) do
|
||||
{:ok, product, status} ->
|
||||
sync_product_associations(product, product_data)
|
||||
{:ok, product, status}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
products
|
||||
|> Task.async_stream(
|
||||
fn product_data -> sync_single_product(conn, product_data) end,
|
||||
max_concurrency: @max_concurrency,
|
||||
timeout: 30_000,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.map(fn
|
||||
{:ok, result} -> result
|
||||
{:exit, :timeout} -> {:error, :timeout}
|
||||
{:exit, reason} -> {:error, reason}
|
||||
end)
|
||||
end
|
||||
|
||||
defp sync_single_product(conn, product_data) do
|
||||
case sync_product(conn, product_data) do
|
||||
{:ok, product, status} ->
|
||||
sync_product_associations(product, product_data)
|
||||
{:ok, product, status}
|
||||
|
||||
error ->
|
||||
Logger.warning(
|
||||
"Failed to sync product #{product_data[:provider_product_id]}: #{inspect(error)}"
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_product(conn, product_data) do
|
||||
attrs = %{
|
||||
provider_product_id: product_data[:provider_product_id],
|
||||
|
||||
Reference in New Issue
Block a user