feat: add Printify webhook endpoint for real-time product updates
- Add /webhooks/printify endpoint with HMAC-SHA256 signature verification - Add Webhooks context to handle product:updated, product:deleted events - Add ProductDeleteWorker for async product deletion - Add webhook API methods to Printify client (create, list, delete) - Add register_webhooks/2 to Printify provider - Add mix register_webhooks task for one-time webhook registration - Cache raw request body in endpoint for signature verification Usage: 1. Generate webhook secret: openssl rand -hex 20 2. Add to provider connection config as "webhook_secret" 3. Register with Printify: mix register_webhooks https://yourshop.com/webhooks/printify Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,41 @@ defmodule SimpleshopTheme.Clients.Printify do
|
||||
get("/shops/#{shop_id}/orders/#{order_id}.json")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Webhooks
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Register a webhook with Printify.
|
||||
|
||||
## Event types
|
||||
- "product:publish:started"
|
||||
- "product:updated"
|
||||
- "product:deleted"
|
||||
- "shop:disconnected"
|
||||
"""
|
||||
def create_webhook(shop_id, url, topic, secret) do
|
||||
post("/shops/#{shop_id}/webhooks.json", %{
|
||||
topic: topic,
|
||||
url: url,
|
||||
secret: secret
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
List registered webhooks for a shop.
|
||||
"""
|
||||
def list_webhooks(shop_id) do
|
||||
get("/shops/#{shop_id}/webhooks.json")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delete a webhook.
|
||||
"""
|
||||
def delete_webhook(shop_id, webhook_id) do
|
||||
delete("/shops/#{shop_id}/webhooks/#{webhook_id}.json")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Download a file from a URL to a local path.
|
||||
"""
|
||||
|
||||
@@ -114,6 +114,66 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Webhook Registration
|
||||
# =============================================================================
|
||||
|
||||
@webhook_events ["product:updated", "product:deleted", "product:publish:started"]
|
||||
|
||||
@doc """
|
||||
Registers webhooks for product events with Printify.
|
||||
|
||||
Returns {:ok, results} or {:error, reason}.
|
||||
"""
|
||||
def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
|
||||
shop_id = config["shop_id"]
|
||||
secret = config["webhook_secret"]
|
||||
|
||||
cond do
|
||||
is_nil(shop_id) ->
|
||||
{:error, :no_shop_id}
|
||||
|
||||
is_nil(secret) or secret == "" ->
|
||||
{:error, :no_webhook_secret}
|
||||
|
||||
true ->
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_api_key(api_key) do
|
||||
results =
|
||||
Enum.map(@webhook_events, fn event ->
|
||||
case Client.create_webhook(shop_id, webhook_url, event, secret) do
|
||||
{:ok, response} -> {:ok, event, response}
|
||||
{:error, reason} -> {:error, event, reason}
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, results}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists registered webhooks for the shop.
|
||||
"""
|
||||
def list_webhooks(%ProviderConnection{config: config} = conn) do
|
||||
shop_id = config["shop_id"]
|
||||
|
||||
if is_nil(shop_id) do
|
||||
{:error, :no_shop_id}
|
||||
else
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_api_key(api_key),
|
||||
{:ok, webhooks} <- Client.list_webhooks(shop_id) do
|
||||
{:ok, webhooks}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Data Normalization
|
||||
# =============================================================================
|
||||
|
||||
45
lib/simpleshop_theme/webhooks.ex
Normal file
45
lib/simpleshop_theme/webhooks.ex
Normal file
@@ -0,0 +1,45 @@
|
||||
defmodule SimpleshopTheme.Webhooks do
|
||||
@moduledoc """
|
||||
Handles incoming webhook events from POD providers.
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.Sync.ProductSyncWorker
|
||||
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Handles a Printify webhook event.
|
||||
|
||||
Returns :ok or {:ok, job} on success, {:error, reason} on failure.
|
||||
"""
|
||||
def handle_printify_event("product:updated", %{"id" => _product_id}) do
|
||||
enqueue_product_sync()
|
||||
end
|
||||
|
||||
def handle_printify_event("product:publish:started", %{"id" => _product_id}) do
|
||||
enqueue_product_sync()
|
||||
end
|
||||
|
||||
def handle_printify_event("product:deleted", %{"id" => product_id}) do
|
||||
ProductDeleteWorker.enqueue(product_id)
|
||||
end
|
||||
|
||||
def handle_printify_event("shop:disconnected", _resource) do
|
||||
Logger.warning("Printify shop disconnected - manual intervention needed")
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle_printify_event(event_type, _resource) do
|
||||
Logger.info("Ignoring unhandled Printify event: #{event_type}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp enqueue_product_sync do
|
||||
case Products.get_provider_connection_by_type("printify") do
|
||||
nil -> {:error, :no_connection}
|
||||
conn -> ProductSyncWorker.enqueue(conn.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
41
lib/simpleshop_theme/webhooks/product_delete_worker.ex
Normal file
41
lib/simpleshop_theme/webhooks/product_delete_worker.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule SimpleshopTheme.Webhooks.ProductDeleteWorker do
|
||||
@moduledoc """
|
||||
Oban worker for deleting products removed from POD providers.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :sync, max_attempts: 3
|
||||
|
||||
alias SimpleshopTheme.Products
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"provider_product_id" => provider_product_id}}) do
|
||||
case Products.get_provider_connection_by_type("printify") do
|
||||
nil ->
|
||||
Logger.warning("No Printify connection found for product deletion")
|
||||
{:cancel, :no_connection}
|
||||
|
||||
conn ->
|
||||
case Products.get_product_by_provider(conn.id, provider_product_id) do
|
||||
nil ->
|
||||
Logger.info("Product #{provider_product_id} already deleted or not found")
|
||||
:ok
|
||||
|
||||
product ->
|
||||
Logger.info("Deleting product #{product.id} (provider: #{provider_product_id})")
|
||||
|
||||
case Products.delete_product(product) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue(provider_product_id) do
|
||||
%{provider_product_id: to_string(provider_product_id)}
|
||||
|> new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user