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:
2026-01-29 08:32:24 +00:00
parent 62faf86abe
commit c5c06d9979
29 changed files with 4162 additions and 8 deletions

View File

@@ -0,0 +1,12 @@
defmodule SimpleshopTheme.Cldr do
@moduledoc """
CLDR backend for internationalization and currency formatting.
Used by ex_money for currency handling.
"""
use Cldr,
locales: ["en"],
default_locale: "en",
providers: [Cldr.Number, Money]
end

View File

@@ -1,4 +1,4 @@
defmodule SimpleshopTheme.Printify.Client do
defmodule SimpleshopTheme.Clients.Printify do
@moduledoc """
HTTP client for the Printify API.
@@ -9,10 +9,14 @@ defmodule SimpleshopTheme.Printify.Client do
@base_url "https://api.printify.com/v1"
@doc """
Get the API token from environment.
Get the API token.
Checks process dictionary first (for provider connections with stored credentials),
then falls back to environment variable (for development/mockup generation).
"""
def api_token do
System.get_env("PRINTIFY_API_TOKEN") ||
Process.get(:printify_api_key) ||
System.get_env("PRINTIFY_API_TOKEN") ||
raise "PRINTIFY_API_TOKEN environment variable is not set"
end
@@ -148,6 +152,15 @@ defmodule SimpleshopTheme.Printify.Client do
get("/shops/#{shop_id}/products/#{product_id}.json")
end
@doc """
List all products in a shop.
"""
def list_products(shop_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
page = Keyword.get(opts, :page, 1)
get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
end
@doc """
Delete a product from a shop.
"""
@@ -155,6 +168,20 @@ defmodule SimpleshopTheme.Printify.Client do
delete("/shops/#{shop_id}/products/#{product_id}.json")
end
@doc """
Create an order in a shop.
"""
def create_order(shop_id, order_data) do
post("/shops/#{shop_id}/orders.json", order_data)
end
@doc """
Get an order by ID.
"""
def get_order(shop_id, order_id) do
get("/shops/#{shop_id}/orders/#{order_id}.json")
end
@doc """
Download a file from a URL to a local path.
"""

View File

@@ -1,4 +1,4 @@
defmodule SimpleshopTheme.Printify.MockupGenerator do
defmodule SimpleshopTheme.Mockups.Generator do
@moduledoc """
Generates product mockups using the Printify API.
@@ -11,7 +11,7 @@ defmodule SimpleshopTheme.Printify.MockupGenerator do
6. Optionally cleaning up created products
"""
alias SimpleshopTheme.Printify.Client
alias SimpleshopTheme.Clients.Printify, as: Client
@output_dir "priv/static/mockups"

View File

@@ -0,0 +1,338 @@
defmodule SimpleshopTheme.Products do
@moduledoc """
The Products context.
Manages products synced from POD providers, including provider connections,
products, images, and variants.
"""
import Ecto.Query
alias SimpleshopTheme.Repo
alias SimpleshopTheme.Products.{ProviderConnection, Product, ProductImage, ProductVariant}
# =============================================================================
# Provider Connections
# =============================================================================
@doc """
Returns the list of provider connections.
"""
def list_provider_connections do
Repo.all(ProviderConnection)
end
@doc """
Gets a single provider connection.
"""
def get_provider_connection(id) do
Repo.get(ProviderConnection, id)
end
@doc """
Gets a provider connection by type.
"""
def get_provider_connection_by_type(provider_type) do
Repo.get_by(ProviderConnection, provider_type: provider_type)
end
@doc """
Creates a provider connection.
"""
def create_provider_connection(attrs \\ %{}) do
%ProviderConnection{}
|> ProviderConnection.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a provider connection.
"""
def update_provider_connection(%ProviderConnection{} = conn, attrs) do
conn
|> ProviderConnection.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a provider connection.
"""
def delete_provider_connection(%ProviderConnection{} = conn) do
Repo.delete(conn)
end
@doc """
Updates the sync status of a provider connection.
"""
def update_sync_status(%ProviderConnection{} = conn, status, synced_at \\ nil) do
attrs = %{sync_status: status}
attrs = if synced_at, do: Map.put(attrs, :last_synced_at, synced_at), else: attrs
conn
|> ProviderConnection.sync_changeset(attrs)
|> Repo.update()
end
# =============================================================================
# Products
# =============================================================================
@doc """
Returns the list of products.
## Options
* `:visible` - filter by visibility (boolean)
* `:status` - filter by status (string)
* `:category` - filter by category (string)
* `:provider_connection_id` - filter by provider connection
* `:preload` - list of associations to preload
"""
def list_products(opts \\ []) do
Product
|> apply_product_filters(opts)
|> order_by([p], desc: p.inserted_at)
|> maybe_preload(opts[:preload])
|> Repo.all()
end
@doc """
Gets a single product by ID.
"""
def get_product(id, opts \\ []) do
Product
|> maybe_preload(opts[:preload])
|> Repo.get(id)
end
@doc """
Gets a single product by slug.
"""
def get_product_by_slug(slug, opts \\ []) do
Product
|> maybe_preload(opts[:preload])
|> Repo.get_by(slug: slug)
end
@doc """
Gets a product by provider connection and provider product ID.
"""
def get_product_by_provider(provider_connection_id, provider_product_id) do
Repo.get_by(Product,
provider_connection_id: provider_connection_id,
provider_product_id: provider_product_id
)
end
@doc """
Creates a product.
"""
def create_product(attrs \\ %{}) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a product.
"""
def update_product(%Product{} = product, attrs) do
product
|> Product.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a product.
"""
def delete_product(%Product{} = product) do
Repo.delete(product)
end
@doc """
Upserts a product from provider data.
Creates a new product if one doesn't exist for the given provider connection
and provider product ID. Updates the existing product if checksum differs.
Returns `{:ok, product, :created | :updated | :unchanged}`.
"""
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)
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
%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
# =============================================================================
# Product Images
# =============================================================================
@doc """
Creates a product image.
"""
def create_product_image(attrs \\ %{}) do
%ProductImage{}
|> ProductImage.changeset(attrs)
|> Repo.insert()
end
@doc """
Deletes all images for a product.
"""
def delete_product_images(%Product{id: product_id}) do
from(i in ProductImage, where: i.product_id == ^product_id)
|> Repo.delete_all()
end
@doc """
Syncs product images from a list of image data.
Deletes existing images and inserts new ones.
"""
def sync_product_images(%Product{id: product_id} = product, images) when is_list(images) do
delete_product_images(product)
images
|> Enum.with_index()
|> Enum.map(fn {image_data, index} ->
attrs =
image_data
|> Map.put(:product_id, product_id)
|> Map.put_new(:position, index)
create_product_image(attrs)
end)
end
# =============================================================================
# Product Variants
# =============================================================================
@doc """
Creates a product variant.
"""
def create_product_variant(attrs \\ %{}) do
%ProductVariant{}
|> ProductVariant.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a product variant.
"""
def update_product_variant(%ProductVariant{} = variant, attrs) do
variant
|> ProductVariant.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes all variants for a product.
"""
def delete_product_variants(%Product{id: product_id}) do
from(v in ProductVariant, where: v.product_id == ^product_id)
|> Repo.delete_all()
end
@doc """
Gets a variant by product and provider variant ID.
"""
def get_variant_by_provider(product_id, provider_variant_id) do
Repo.get_by(ProductVariant,
product_id: product_id,
provider_variant_id: provider_variant_id
)
end
@doc """
Syncs product variants from a list of variant data.
Upserts variants based on provider_variant_id.
"""
def sync_product_variants(%Product{id: product_id}, variants) when is_list(variants) do
existing_ids =
from(v in ProductVariant,
where: v.product_id == ^product_id,
select: v.provider_variant_id
)
|> Repo.all()
|> MapSet.new()
incoming_ids =
variants
|> Enum.map(&(&1[:provider_variant_id] || &1["provider_variant_id"]))
|> MapSet.new()
# Delete variants that are no longer in the incoming list
removed_ids = MapSet.difference(existing_ids, incoming_ids)
if MapSet.size(removed_ids) > 0 do
from(v in ProductVariant,
where: v.product_id == ^product_id and v.provider_variant_id in ^MapSet.to_list(removed_ids)
)
|> Repo.delete_all()
end
# Upsert incoming variants
Enum.map(variants, fn variant_data ->
provider_variant_id = variant_data[:provider_variant_id] || variant_data["provider_variant_id"]
attrs = Map.put(variant_data, :product_id, product_id)
case get_variant_by_provider(product_id, provider_variant_id) do
nil ->
create_product_variant(attrs)
existing ->
update_product_variant(existing, attrs)
end
end)
end
# =============================================================================
# Private Helpers
# =============================================================================
defp apply_product_filters(query, opts) do
query
|> filter_by_visible(opts[:visible])
|> filter_by_status(opts[:status])
|> filter_by_category(opts[:category])
|> filter_by_provider_connection(opts[:provider_connection_id])
end
defp filter_by_visible(query, nil), do: query
defp filter_by_visible(query, visible), do: where(query, [p], p.visible == ^visible)
defp filter_by_status(query, nil), do: query
defp filter_by_status(query, status), do: where(query, [p], p.status == ^status)
defp filter_by_category(query, nil), do: query
defp filter_by_category(query, category), do: where(query, [p], p.category == ^category)
defp filter_by_provider_connection(query, nil), do: query
defp filter_by_provider_connection(query, conn_id),
do: where(query, [p], p.provider_connection_id == ^conn_id)
defp maybe_preload(query, nil), do: query
defp maybe_preload(query, preloads), do: preload(query, ^preloads)
end

View File

@@ -0,0 +1,108 @@
defmodule SimpleshopTheme.Products.Product do
@moduledoc """
Schema for products synced from POD providers.
Products are uniquely identified by the combination of
provider_connection_id and provider_product_id.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@statuses ~w(active draft archived)
schema "products" do
field :provider_product_id, :string
field :title, :string
field :description, :string
field :slug, :string
field :status, :string, default: "active"
field :visible, :boolean, default: true
field :category, :string
field :provider_data, :map, default: %{}
field :checksum, :string
belongs_to :provider_connection, SimpleshopTheme.Products.ProviderConnection
has_many :images, SimpleshopTheme.Products.ProductImage
has_many :variants, SimpleshopTheme.Products.ProductVariant
timestamps(type: :utc_datetime)
end
@doc """
Returns the list of valid product statuses.
"""
def statuses, do: @statuses
@doc """
Changeset for creating or updating a product.
"""
def changeset(product, attrs) do
product
|> cast(attrs, [
:provider_connection_id,
:provider_product_id,
:title,
:description,
:slug,
:status,
:visible,
:category,
:provider_data,
:checksum
])
|> generate_slug_if_missing()
|> validate_required([:provider_connection_id, :provider_product_id, :title, :slug])
|> validate_inclusion(:status, @statuses)
|> unique_constraint(:slug)
|> unique_constraint([:provider_connection_id, :provider_product_id])
end
@doc """
Generates a checksum from provider data for detecting changes.
"""
def compute_checksum(provider_data) when is_map(provider_data) do
provider_data
|> Jason.encode!()
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
|> binary_part(0, 16)
end
def compute_checksum(_), do: nil
defp generate_slug_if_missing(changeset) do
case get_field(changeset, :slug) do
nil ->
title = get_change(changeset, :title) || get_field(changeset, :title)
if title do
slug = Slug.slugify(title)
put_change(changeset, :slug, slug)
else
changeset
end
_ ->
changeset
end
end
end
defmodule Slug do
@moduledoc false
def slugify(nil), do: nil
def slugify(string) when is_binary(string) do
string
|> String.downcase()
|> String.replace(~r/[^\w\s-]/, "")
|> String.replace(~r/\s+/, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
end
end

View File

@@ -0,0 +1,33 @@
defmodule SimpleshopTheme.Products.ProductImage do
@moduledoc """
Schema for product images.
Images are ordered by position and belong to a single product.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "product_images" do
field :src, :string
field :position, :integer, default: 0
field :alt, :string
belongs_to :product, SimpleshopTheme.Products.Product
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating or updating a product image.
"""
def changeset(product_image, attrs) do
product_image
|> cast(attrs, [:product_id, :src, :position, :alt])
|> validate_required([:product_id, :src])
|> foreign_key_constraint(:product_id)
end
end

View File

@@ -0,0 +1,98 @@
defmodule SimpleshopTheme.Products.ProductVariant do
@moduledoc """
Schema for product variants.
Variants represent different options (size, color, etc.) for a product.
Each variant has its own pricing and availability.
## Options Field
The `options` field stores variant options as a map with human-readable labels:
%{
"Size" => "Large",
"Color" => "Navy Blue"
}
Labels are denormalized during sync for efficient display.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "product_variants" do
field :provider_variant_id, :string
field :title, :string
field :sku, :string
field :price, :integer
field :compare_at_price, :integer
field :cost, :integer
field :options, :map, default: %{}
field :is_enabled, :boolean, default: true
field :is_available, :boolean, default: true
belongs_to :product, SimpleshopTheme.Products.Product
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating or updating a product variant.
"""
def changeset(product_variant, attrs) do
product_variant
|> cast(attrs, [
:product_id,
:provider_variant_id,
:title,
:sku,
:price,
:compare_at_price,
:cost,
:options,
:is_enabled,
:is_available
])
|> validate_required([:product_id, :provider_variant_id, :title, :price])
|> validate_number(:price, greater_than_or_equal_to: 0)
|> validate_number(:compare_at_price, greater_than_or_equal_to: 0)
|> validate_number(:cost, greater_than_or_equal_to: 0)
|> unique_constraint([:product_id, :provider_variant_id])
|> foreign_key_constraint(:product_id)
end
@doc """
Returns the profit for this variant (price - cost).
Returns nil if cost is not set.
"""
def profit(%__MODULE__{price: price, cost: cost}) when is_integer(price) and is_integer(cost) do
price - cost
end
def profit(_), do: nil
@doc """
Returns true if the variant is on sale (has a compare_at_price higher than price).
"""
def on_sale?(%__MODULE__{price: price, compare_at_price: compare_at})
when is_integer(price) and is_integer(compare_at) and compare_at > price do
true
end
def on_sale?(_), do: false
@doc """
Formats the options as a human-readable title.
E.g., %{"Size" => "Large", "Color" => "Blue"} -> "Large / Blue"
"""
def options_title(%__MODULE__{options: options}) when is_map(options) and map_size(options) > 0 do
options
|> Map.values()
|> Enum.join(" / ")
end
def options_title(_), do: nil
end

View File

@@ -0,0 +1,97 @@
defmodule SimpleshopTheme.Products.ProviderConnection do
@moduledoc """
Schema for POD provider connections.
Stores encrypted API credentials and configuration for each provider.
Only one connection per provider type is allowed.
"""
use Ecto.Schema
import Ecto.Changeset
alias SimpleshopTheme.Vault
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@provider_types ~w(printify gelato prodigi printful)
@sync_statuses ~w(pending syncing completed failed)
schema "provider_connections" do
field :provider_type, :string
field :name, :string
field :enabled, :boolean, default: true
field :api_key_encrypted, :binary
field :config, :map, default: %{}
field :last_synced_at, :utc_datetime
field :sync_status, :string, default: "pending"
# Virtual field for setting API key
field :api_key, :string, virtual: true
has_many :products, SimpleshopTheme.Products.Product
timestamps(type: :utc_datetime)
end
@doc """
Returns the list of supported provider types.
"""
def provider_types, do: @provider_types
@doc """
Returns the list of valid sync statuses.
"""
def sync_statuses, do: @sync_statuses
@doc """
Changeset for creating a new provider connection.
"""
def changeset(provider_connection, attrs) do
provider_connection
|> cast(attrs, [:provider_type, :name, :enabled, :api_key, :config])
|> validate_required([:provider_type, :name])
|> validate_inclusion(:provider_type, @provider_types)
|> unique_constraint(:provider_type)
|> encrypt_api_key()
end
@doc """
Changeset for updating sync status.
"""
def sync_changeset(provider_connection, attrs) do
provider_connection
|> cast(attrs, [:last_synced_at, :sync_status])
|> validate_inclusion(:sync_status, @sync_statuses)
end
@doc """
Decrypts and returns the API key for a provider connection.
"""
def get_api_key(%__MODULE__{api_key_encrypted: nil}), do: nil
def get_api_key(%__MODULE__{api_key_encrypted: encrypted}) do
case Vault.decrypt(encrypted) do
{:ok, api_key} -> api_key
{:error, _} -> nil
end
end
defp encrypt_api_key(changeset) do
case get_change(changeset, :api_key) do
nil ->
changeset
api_key ->
case Vault.encrypt(api_key) do
{:ok, encrypted} ->
changeset
|> put_change(:api_key_encrypted, encrypted)
|> delete_change(:api_key)
{:error, _} ->
add_error(changeset, :api_key, "could not be encrypted")
end
end
end
end

View 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

View 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

View File

@@ -0,0 +1,148 @@
defmodule SimpleshopTheme.Sync.ProductSyncWorker do
@moduledoc """
Oban worker for syncing products from POD providers.
This worker fetches products from a provider, normalizes them,
and upserts them into the local database.
## Usage
# Enqueue a sync for a provider connection
ProductSyncWorker.enqueue(provider_connection_id)
## Job Args
* `provider_connection_id` - The ID of the provider connection to sync
"""
use Oban.Worker, queue: :sync, max_attempts: 3
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Providers.Provider
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"provider_connection_id" => conn_id}}) do
case Products.get_provider_connection(conn_id) do
nil ->
{:cancel, :connection_not_found}
%ProviderConnection{enabled: false} ->
{:cancel, :connection_disabled}
conn ->
sync_products(conn)
end
end
@doc """
Enqueue a product sync for a provider connection.
"""
def enqueue(provider_connection_id) do
%{provider_connection_id: provider_connection_id}
|> new()
|> Oban.insert()
end
@doc """
Enqueue a product sync with a delay.
"""
def enqueue(provider_connection_id, delay_seconds) when is_integer(delay_seconds) do
%{provider_connection_id: provider_connection_id}
|> new(scheduled_at: DateTime.add(DateTime.utc_now(), delay_seconds, :second))
|> Oban.insert()
end
# =============================================================================
# Private
# =============================================================================
defp sync_products(conn) do
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
Products.update_sync_status(conn, "syncing")
with {:ok, provider} <- Provider.for_connection(conn),
{:ok, products} <- provider.fetch_products(conn) do
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)
Logger.info(
"Product sync complete for #{conn.provider_type}: " <>
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
)
Products.update_sync_status(conn, "completed", DateTime.utc_now())
:ok
else
{:error, reason} = error ->
Logger.error("Product sync failed for #{conn.provider_type}: #{inspect(reason)}")
Products.update_sync_status(conn, "failed")
error
end
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
end)
end
defp sync_product(conn, product_data) do
attrs = %{
provider_product_id: product_data[:provider_product_id],
title: product_data[:title],
description: product_data[:description],
category: product_data[:category],
provider_data: product_data[:provider_data]
}
Products.upsert_product(conn, attrs)
end
defp sync_product_associations(product, product_data) do
# Sync images
images =
(product_data[:images] || [])
|> Enum.map(fn img ->
%{
src: img[:src],
position: img[:position],
alt: img[:alt]
}
end)
Products.sync_product_images(product, images)
# Sync variants
variants =
(product_data[:variants] || [])
|> Enum.map(fn var ->
%{
provider_variant_id: var[:provider_variant_id],
title: var[:title],
sku: var[:sku],
price: var[:price],
cost: var[:cost],
options: var[:options],
is_enabled: var[:is_enabled],
is_available: var[:is_available]
}
end)
Products.sync_product_variants(product, variants)
end
end

View File

@@ -0,0 +1,101 @@
defmodule SimpleshopTheme.Vault do
@moduledoc """
Handles encryption and decryption of sensitive data.
Uses AES-256-GCM for authenticated encryption.
Keys are derived from the application's secret_key_base.
"""
@aad "SimpleshopTheme.Vault"
@doc """
Encrypts a string value.
Returns `{:ok, encrypted_binary}` or `{:error, reason}`.
The encrypted binary includes the IV and auth tag.
"""
@spec encrypt(String.t()) :: {:ok, binary()} | {:error, term()}
def encrypt(plaintext) when is_binary(plaintext) do
key = derive_key()
iv = :crypto.strong_rand_bytes(12)
{ciphertext, tag} =
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, plaintext, @aad, true)
# Format: iv (12 bytes) + tag (16 bytes) + ciphertext
{:ok, iv <> tag <> ciphertext}
rescue
e -> {:error, e}
end
def encrypt(nil), do: {:ok, nil}
@doc """
Decrypts an encrypted binary.
Returns `{:ok, plaintext}` or `{:error, reason}`.
"""
@spec decrypt(binary()) :: {:ok, String.t()} | {:error, term()}
def decrypt(<<iv::binary-12, tag::binary-16, ciphertext::binary>>) do
key = derive_key()
case :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, ciphertext, @aad, tag, false) do
plaintext when is_binary(plaintext) ->
{:ok, plaintext}
:error ->
{:error, :decryption_failed}
end
rescue
e -> {:error, e}
end
def decrypt(nil), do: {:ok, nil}
def decrypt(""), do: {:ok, ""}
def decrypt(_invalid) do
{:error, :invalid_ciphertext}
end
@doc """
Encrypts a string value, raising on error.
"""
@spec encrypt!(String.t()) :: binary()
def encrypt!(plaintext) do
case encrypt(plaintext) do
{:ok, ciphertext} -> ciphertext
{:error, reason} -> raise "Encryption failed: #{inspect(reason)}"
end
end
@doc """
Decrypts an encrypted binary, raising on error.
"""
@spec decrypt!(binary()) :: String.t()
def decrypt!(ciphertext) do
case decrypt(ciphertext) do
{:ok, plaintext} -> plaintext
{:error, reason} -> raise "Decryption failed: #{inspect(reason)}"
end
end
# Derives a 32-byte key from the secret_key_base
defp derive_key do
secret_key_base = get_secret_key_base()
:crypto.hash(:sha256, secret_key_base <> "vault_encryption_key")
end
defp get_secret_key_base do
case Application.get_env(:simpleshop_theme, SimpleshopThemeWeb.Endpoint)[:secret_key_base] do
nil ->
raise """
Secret key base is not configured.
Set it in config/runtime.exs or config/dev.exs.
"""
key when is_binary(key) ->
key
end
end
end