feat: add Products context with provider integration (Phase 1)
Implement the schema foundation for syncing products from POD providers like Printify. This includes encrypted credential storage, product/variant schemas, and an Oban worker for background sync. New modules: - Vault: AES-256-GCM encryption for API keys - Products context: CRUD and sync operations for products - Provider behaviour: abstraction for POD provider implementations - ProductSyncWorker: Oban job for async product sync Schemas: ProviderConnection, Product, ProductImage, ProductVariant Also reorganizes Printify client to lib/simpleshop_theme/clients/ and mockup generator to lib/simpleshop_theme/mockups/ for better structure. 134 tests added covering all new functionality. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
251
lib/simpleshop_theme/providers/printify.ex
Normal file
251
lib/simpleshop_theme/providers/printify.ex
Normal file
@@ -0,0 +1,251 @@
|
||||
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, response} <- Client.list_products(shop_id) do
|
||||
products =
|
||||
response["data"]
|
||||
|> Enum.map(&normalize_product/1)
|
||||
|
||||
{:ok, products}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
75
lib/simpleshop_theme/providers/provider.ex
Normal file
75
lib/simpleshop_theme/providers/provider.ex
Normal file
@@ -0,0 +1,75 @@
|
||||
defmodule SimpleshopTheme.Providers.Provider do
|
||||
@moduledoc """
|
||||
Behaviour for POD provider integrations.
|
||||
|
||||
Each provider (Printify, Gelato, Prodigi, etc.) implements this behaviour
|
||||
to provide a consistent interface for:
|
||||
|
||||
- Testing connections
|
||||
- Fetching products
|
||||
- Submitting orders
|
||||
- Tracking order status
|
||||
|
||||
## Data Normalization
|
||||
|
||||
Providers return normalized data structures:
|
||||
|
||||
- Products are maps with keys: `title`, `description`, `provider_product_id`,
|
||||
`images`, `variants`, `category`, `provider_data`
|
||||
- Variants are maps with keys: `provider_variant_id`, `title`, `sku`, `price`,
|
||||
`cost`, `options`, `is_enabled`, `is_available`
|
||||
- Images are maps with keys: `src`, `position`, `alt`
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Products.ProviderConnection
|
||||
|
||||
@doc """
|
||||
Returns the provider type identifier (e.g., "printify", "gelato").
|
||||
"""
|
||||
@callback provider_type() :: String.t()
|
||||
|
||||
@doc """
|
||||
Tests the connection to the provider.
|
||||
|
||||
Returns `{:ok, info}` with provider-specific info (e.g., shop name)
|
||||
or `{:error, reason}` if the connection fails.
|
||||
"""
|
||||
@callback test_connection(ProviderConnection.t()) :: {:ok, map()} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Fetches all products from the provider.
|
||||
|
||||
Returns a list of normalized product maps.
|
||||
"""
|
||||
@callback fetch_products(ProviderConnection.t()) :: {:ok, [map()]} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Submits an order to the provider for fulfillment.
|
||||
|
||||
Returns `{:ok, %{provider_order_id: String.t()}}` on success.
|
||||
"""
|
||||
@callback submit_order(ProviderConnection.t(), order :: map()) ::
|
||||
{:ok, %{provider_order_id: String.t()}} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Gets the current status of an order from the provider.
|
||||
"""
|
||||
@callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a given provider type.
|
||||
"""
|
||||
def for_type("printify"), do: {:ok, SimpleshopTheme.Providers.Printify}
|
||||
def for_type("gelato"), do: {:error, :not_implemented}
|
||||
def for_type("prodigi"), do: {:error, :not_implemented}
|
||||
def for_type("printful"), do: {:error, :not_implemented}
|
||||
def for_type(type), do: {:error, {:unknown_provider, type}}
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a provider connection.
|
||||
"""
|
||||
def for_connection(%ProviderConnection{provider_type: type}) do
|
||||
for_type(type)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user