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:
jamey
2026-01-31 22:08:34 +00:00
parent bbd748f123
commit 5b736b99fd
17 changed files with 1352 additions and 38 deletions

View File

@@ -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

View File

@@ -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
# =============================================================================

View File

@@ -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

View 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

View File

@@ -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"]

View File

@@ -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],