- Add /webhooks/printify endpoint with HMAC-SHA256 signature verification - Add Webhooks context to handle product:updated, product:deleted events - Add ProductDeleteWorker for async product deletion - Add webhook API methods to Printify client (create, list, delete) - Add register_webhooks/2 to Printify provider - Add mix register_webhooks task for one-time webhook registration - Cache raw request body in endpoint for signature verification Usage: 1. Generate webhook secret: openssl rand -hex 20 2. Add to provider connection config as "webhook_secret" 3. Register with Printify: mix register_webhooks https://yourshop.com/webhooks/printify Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
335 lines
9.9 KiB
Elixir
335 lines
9.9 KiB
Elixir
defmodule SimpleshopTheme.Providers.Printify do
|
|
@moduledoc """
|
|
Printify provider implementation.
|
|
|
|
Handles product sync and order submission for Printify.
|
|
"""
|
|
|
|
@behaviour SimpleshopTheme.Providers.Provider
|
|
|
|
alias SimpleshopTheme.Clients.Printify, as: Client
|
|
alias SimpleshopTheme.Products.ProviderConnection
|
|
|
|
@impl true
|
|
def provider_type, do: "printify"
|
|
|
|
@impl true
|
|
def test_connection(%ProviderConnection{} = conn) do
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, shops} <- Client.get_shops() do
|
|
shop = List.first(shops)
|
|
|
|
{:ok,
|
|
%{
|
|
shop_id: shop["id"],
|
|
shop_name: shop["title"],
|
|
shop_count: length(shops)
|
|
}}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def fetch_products(%ProviderConnection{config: config} = conn) do
|
|
shop_id = config["shop_id"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, products} <- fetch_all_products(shop_id) do
|
|
{:ok, products}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
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"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
order_data <- build_order_payload(order),
|
|
{:ok, response} <- Client.create_order(shop_id, order_data) do
|
|
{:ok, %{provider_order_id: response["id"]}}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def get_order_status(%ProviderConnection{config: config} = conn, provider_order_id) do
|
|
shop_id = config["shop_id"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, response} <- Client.get_order(shop_id, provider_order_id) do
|
|
{:ok, normalize_order_status(response)}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Webhook Registration
|
|
# =============================================================================
|
|
|
|
@webhook_events ["product:updated", "product:deleted", "product:publish:started"]
|
|
|
|
@doc """
|
|
Registers webhooks for product events with Printify.
|
|
|
|
Returns {:ok, results} or {:error, reason}.
|
|
"""
|
|
def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
|
|
shop_id = config["shop_id"]
|
|
secret = config["webhook_secret"]
|
|
|
|
cond do
|
|
is_nil(shop_id) ->
|
|
{:error, :no_shop_id}
|
|
|
|
is_nil(secret) or secret == "" ->
|
|
{:error, :no_webhook_secret}
|
|
|
|
true ->
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key) do
|
|
results =
|
|
Enum.map(@webhook_events, fn event ->
|
|
case Client.create_webhook(shop_id, webhook_url, event, secret) do
|
|
{:ok, response} -> {:ok, event, response}
|
|
{:error, reason} -> {:error, event, reason}
|
|
end
|
|
end)
|
|
|
|
{:ok, results}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Lists registered webhooks for the shop.
|
|
"""
|
|
def list_webhooks(%ProviderConnection{config: config} = conn) do
|
|
shop_id = config["shop_id"]
|
|
|
|
if is_nil(shop_id) do
|
|
{:error, :no_shop_id}
|
|
else
|
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
|
:ok <- set_api_key(api_key),
|
|
{:ok, webhooks} <- Client.list_webhooks(shop_id) do
|
|
{:ok, webhooks}
|
|
else
|
|
nil -> {:error, :no_api_key}
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Data Normalization
|
|
# =============================================================================
|
|
|
|
defp normalize_product(raw) do
|
|
%{
|
|
provider_product_id: to_string(raw["id"]),
|
|
title: raw["title"],
|
|
description: raw["description"],
|
|
category: extract_category(raw),
|
|
images: normalize_images(raw["images"] || []),
|
|
variants: normalize_variants(raw["variants"] || []),
|
|
provider_data: %{
|
|
blueprint_id: raw["blueprint_id"],
|
|
print_provider_id: raw["print_provider_id"],
|
|
tags: raw["tags"] || [],
|
|
options: raw["options"] || [],
|
|
raw: raw
|
|
}
|
|
}
|
|
end
|
|
|
|
defp normalize_images(images) do
|
|
images
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {img, index} ->
|
|
%{
|
|
src: img["src"],
|
|
position: img["position"] || index,
|
|
alt: nil
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp normalize_variants(variants) do
|
|
Enum.map(variants, fn var ->
|
|
%{
|
|
provider_variant_id: to_string(var["id"]),
|
|
title: var["title"],
|
|
sku: var["sku"],
|
|
price: var["price"],
|
|
cost: var["cost"],
|
|
options: normalize_variant_options(var),
|
|
is_enabled: var["is_enabled"] == true,
|
|
is_available: var["is_available"] == true
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp normalize_variant_options(variant) do
|
|
# Printify variants have options as a list of option value IDs
|
|
# We need to build the human-readable map from the variant title
|
|
# Format: "Size / Color" -> %{"Size" => "Large", "Color" => "Blue"}
|
|
|
|
title = variant["title"] || ""
|
|
parts = String.split(title, " / ")
|
|
|
|
# Common option names based on position
|
|
option_names = ["Size", "Color", "Style"]
|
|
|
|
parts
|
|
|> Enum.with_index()
|
|
|> Enum.reduce(%{}, fn {value, index}, acc ->
|
|
key = Enum.at(option_names, index) || "Option #{index + 1}"
|
|
Map.put(acc, key, value)
|
|
end)
|
|
end
|
|
|
|
defp extract_category(raw) do
|
|
# Try to extract category from tags
|
|
tags = raw["tags"] || []
|
|
|
|
cond do
|
|
"apparel" in tags or "clothing" in tags -> "Apparel"
|
|
"homeware" in tags or "home" in tags -> "Homewares"
|
|
"accessories" in tags -> "Accessories"
|
|
"art" in tags or "print" in tags -> "Art Prints"
|
|
true -> nil
|
|
end
|
|
end
|
|
|
|
defp normalize_order_status(raw) do
|
|
%{
|
|
status: map_order_status(raw["status"]),
|
|
provider_status: raw["status"],
|
|
tracking_number: extract_tracking(raw),
|
|
tracking_url: extract_tracking_url(raw),
|
|
shipments: raw["shipments"] || []
|
|
}
|
|
end
|
|
|
|
defp map_order_status("pending"), do: "pending"
|
|
defp map_order_status("on-hold"), do: "pending"
|
|
defp map_order_status("payment-not-received"), do: "pending"
|
|
defp map_order_status("in-production"), do: "processing"
|
|
defp map_order_status("partially-shipped"), do: "processing"
|
|
defp map_order_status("shipped"), do: "shipped"
|
|
defp map_order_status("delivered"), do: "delivered"
|
|
defp map_order_status("canceled"), do: "cancelled"
|
|
defp map_order_status(_), do: "pending"
|
|
|
|
defp extract_tracking(raw) do
|
|
case raw["shipments"] do
|
|
[shipment | _] -> shipment["tracking_number"]
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_tracking_url(raw) do
|
|
case raw["shipments"] do
|
|
[shipment | _] -> shipment["tracking_url"]
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# =============================================================================
|
|
# Order Building
|
|
# =============================================================================
|
|
|
|
defp build_order_payload(order) do
|
|
%{
|
|
external_id: order.order_number,
|
|
label: order.order_number,
|
|
line_items:
|
|
Enum.map(order.line_items, fn item ->
|
|
%{
|
|
product_id: item.product_variant.product.provider_product_id,
|
|
variant_id: String.to_integer(item.product_variant.provider_variant_id),
|
|
quantity: item.quantity
|
|
}
|
|
end),
|
|
shipping_method: 1,
|
|
address_to: %{
|
|
first_name: order.shipping_address["first_name"],
|
|
last_name: order.shipping_address["last_name"],
|
|
email: order.customer_email,
|
|
phone: order.shipping_address["phone"],
|
|
country: order.shipping_address["country"],
|
|
region: order.shipping_address["state"] || order.shipping_address["region"],
|
|
address1: order.shipping_address["address1"],
|
|
address2: order.shipping_address["address2"],
|
|
city: order.shipping_address["city"],
|
|
zip: order.shipping_address["zip"] || order.shipping_address["postal_code"]
|
|
}
|
|
}
|
|
end
|
|
|
|
# =============================================================================
|
|
# API Key Management
|
|
# =============================================================================
|
|
|
|
# Temporarily sets the API key for the request
|
|
# In a production system, this would use a connection pool or request context
|
|
defp set_api_key(api_key) do
|
|
Process.put(:printify_api_key, api_key)
|
|
:ok
|
|
end
|
|
end
|