berrypod/lib/simpleshop_theme/clients/printful.ex
jamey 1aceaf9444 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>
2026-02-15 16:52:53 +00:00

366 lines
9.9 KiB
Elixir

defmodule SimpleshopTheme.Clients.Printful do
@moduledoc """
HTTP client for the Printful API.
Uses v2 endpoints where available, v1 for sync products (store products).
Handles authentication via Bearer tokens stored in the process dictionary.
"""
@base_url "https://api.printful.com"
# =============================================================================
# Auth
# =============================================================================
@doc """
Get the API token.
Checks process dictionary first (for provider connections with stored credentials),
then falls back to environment variable (for development/testing).
"""
def api_token do
Process.get(:printful_api_key) ||
System.get_env("PRINTFUL_API_TOKEN") ||
raise "PRINTFUL_API_TOKEN environment variable is not set"
end
@doc """
Get the store ID from the process dictionary.
"""
def store_id do
Process.get(:printful_store_id)
end
# =============================================================================
# Core HTTP
# =============================================================================
@doc """
Make a GET request to the Printful API.
"""
def get(path, _opts \\ []) do
url = @base_url <> path
case Req.get(url, headers: auth_headers(), receive_timeout: 30_000) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, unwrap_response(path, body)}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Make a POST request to the Printful API.
"""
def post(path, body, _opts \\ []) do
url = @base_url <> path
case Req.post(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, unwrap_response(path, body)}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Make a DELETE request to the Printful API.
"""
def delete(path, _opts \\ []) do
url = @base_url <> path
case Req.delete(url, headers: auth_headers(), receive_timeout: 30_000) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, unwrap_response(path, body)}
{:ok, %Req.Response{status: 204}} ->
{:ok, nil}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
end
# v1 responses wrap data in "result", v2 wraps in "data"
defp unwrap_response("/v2/" <> _, %{"data" => data}), do: data
defp unwrap_response(_path, %{"code" => _, "result" => result}), do: result
defp unwrap_response(_path, body), do: body
# =============================================================================
# Stores (v2)
# =============================================================================
@doc """
List all stores for the authenticated account.
"""
def get_stores do
get("/v2/stores")
end
@doc """
Get the first store's ID.
"""
def get_store_id do
case get_stores() do
{:ok, stores} when is_list(stores) and stores != [] ->
{:ok, hd(stores)["id"]}
{:ok, []} ->
{:error, :no_stores}
error ->
error
end
end
# =============================================================================
# Catalogue (v2)
# =============================================================================
@doc """
Get a catalogue product by ID.
"""
def get_catalog_product(product_id) do
get("/v2/catalog-products/#{product_id}")
end
@doc """
Get variants for a catalogue product.
"""
def get_catalog_variants(product_id) do
get("/v2/catalog-products/#{product_id}/catalog-variants")
end
@doc """
Get stock availability for a catalogue product.
"""
def get_product_availability(product_id) do
get("/v2/catalog-products/#{product_id}/availability")
end
# =============================================================================
# Sync Products (v1 — no v2 equivalent)
# =============================================================================
@doc """
List the seller's configured products (sync products).
These are products the seller has set up in Printful's dashboard with designs.
Supports pagination via `offset` and `limit` options.
"""
def list_sync_products(opts \\ []) do
offset = Keyword.get(opts, :offset, 0)
limit = Keyword.get(opts, :limit, 20)
get("/store/products?offset=#{offset}&limit=#{limit}")
end
@doc """
Get a single sync product with all its variants and files.
"""
def get_sync_product(product_id) 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)
# =============================================================================
@doc """
Calculate shipping rates for a set of items to a recipient.
## Example
calculate_shipping(
%{country_code: "GB"},
[%{source: "catalog", catalog_variant_id: 474, quantity: 1}]
)
"""
def calculate_shipping(recipient, items) do
post("/v2/shipping-rates", %{recipient: recipient, order_items: items})
end
# =============================================================================
# Orders (v2)
# =============================================================================
@doc """
Create a new order (draft status).
"""
def create_order(order_data) do
post("/v2/orders", order_data)
end
@doc """
Confirm an order for fulfilment. This triggers charges and production.
"""
def confirm_order(order_id) do
post("/v2/orders/#{order_id}/confirmation", %{})
end
@doc """
Get an order by ID.
"""
def get_order(order_id) do
get("/v2/orders/#{order_id}")
end
@doc """
Get shipments for an order.
"""
def get_order_shipments(order_id) do
get("/v2/orders/#{order_id}/shipments")
end
# =============================================================================
# Mockups (v2)
# =============================================================================
@doc """
Create a mockup generation task.
"""
def create_mockup_task(body) do
post("/v2/mockup-tasks", body)
end
@doc """
Get mockup task results. Pass `task_id` to poll a specific task.
"""
def get_mockup_tasks(params \\ %{}) do
query = URI.encode_query(params)
path = if query == "", do: "/v2/mockup-tasks", else: "/v2/mockup-tasks?#{query}"
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)
# =============================================================================
@doc """
Upload a file to the Printful file library.
"""
def upload_file(url) do
post("/v2/files", %{url: url})
end
# =============================================================================
# Webhooks (v2)
# =============================================================================
@doc """
Set up webhook configuration.
"""
def setup_webhooks(url, events) do
post("/v2/webhooks", %{url: url, events: events})
end
@doc """
Get current webhook configuration.
"""
def get_webhooks do
get("/v2/webhooks")
end
@doc """
Disable webhook support.
"""
def delete_webhooks do
delete("/v2/webhooks")
end
# =============================================================================
# File Downloads
# =============================================================================
@doc """
Download a file from a URL to a local path.
"""
def 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
# =============================================================================
# Private
# =============================================================================
defp auth_headers do
headers = [
{"Authorization", "Bearer #{api_token()}"},
{"Content-Type", "application/json"}
]
case store_id() do
nil -> headers
id -> [{"X-PF-Store-Id", to_string(id)} | headers]
end
end
end