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:
jamey
2026-01-31 22:41:15 +00:00
parent a2157177b8
commit a9c15ea6ae
13 changed files with 596 additions and 4 deletions

View File

@@ -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.
"""

View File

@@ -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
# =============================================================================

View 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

View 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