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:
12
lib/simpleshop_theme/cldr.ex
Normal file
12
lib/simpleshop_theme/cldr.ex
Normal 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
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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"
|
||||
|
||||
338
lib/simpleshop_theme/products.ex
Normal file
338
lib/simpleshop_theme/products.ex
Normal 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
|
||||
108
lib/simpleshop_theme/products/product.ex
Normal file
108
lib/simpleshop_theme/products/product.ex
Normal 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
|
||||
33
lib/simpleshop_theme/products/product_image.ex
Normal file
33
lib/simpleshop_theme/products/product_image.ex
Normal 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
|
||||
98
lib/simpleshop_theme/products/product_variant.ex
Normal file
98
lib/simpleshop_theme/products/product_variant.ex
Normal 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
|
||||
97
lib/simpleshop_theme/products/provider_connection.ex
Normal file
97
lib/simpleshop_theme/products/provider_connection.ex
Normal 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
|
||||
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
|
||||
148
lib/simpleshop_theme/sync/product_sync_worker.ex
Normal file
148
lib/simpleshop_theme/sync/product_sync_worker.ex
Normal 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
|
||||
101
lib/simpleshop_theme/vault.ex
Normal file
101
lib/simpleshop_theme/vault.ex
Normal 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
|
||||
Reference in New Issue
Block a user