diff --git a/PROGRESS.md b/PROGRESS.md index ffb62a6..74dadc7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -52,12 +52,12 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc | 16 | Variant refinement with live data | — | 2-3h | | | ~~18~~ | ~~Shipping costs at checkout~~ | 17 | 4h | done | | | **Printful integration** | | | | -| 24 | Printful HTTP client | — | 1.5h | | -| 25 | Printful provider (sync + orders) | 24 | 3h | | -| 26 | Multi-provider order routing | 25 | 1h | | -| 27 | Printful shipping rates | 25 | 1.5h | | -| 28 | Printful mockup generation worker | 25 | 2h | | -| 29 | Printful webhooks | 25 | 1.5h | | +| ~~24~~ | ~~Printful HTTP client~~ | — | 1.5h | done | +| ~~25~~ | ~~Printful provider (sync + orders)~~ | 24 | 3h | done | +| ~~26~~ | ~~Multi-provider order routing~~ | 25 | 1h | done | +| ~~27~~ | ~~Printful shipping rates~~ | 25 | 1.5h | done | +| ~~28~~ | ~~Printful mockup generation worker~~ | 25 | — | done (existing pipeline) | +| ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done | | 30 | Admin UI tweaks for Printful | 25 | 1h | | | 31 | Printful tests + integration testing | 24-30 | 4.5h | | | | **CSS migration (after admin stable)** | | | | diff --git a/lib/simpleshop_theme/providers/printful.ex b/lib/simpleshop_theme/providers/printful.ex index 432c1ac..142c516 100644 --- a/lib/simpleshop_theme/providers/printful.ex +++ b/lib/simpleshop_theme/providers/printful.ex @@ -504,6 +504,69 @@ defmodule SimpleshopTheme.Providers.Printful do } end + # ============================================================================= + # Webhooks + # ============================================================================= + + @webhook_events [ + "package_shipped", + "package_returned", + "order_failed", + "order_canceled", + "product_synced", + "product_updated", + "product_deleted" + ] + + @doc """ + Registers webhooks with Printful for this store. + + The webhook URL should include a token query param for verification, + e.g. `https://example.com/webhooks/printful?token=SECRET`. + """ + def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do + store_id = config["store_id"] + secret = config["webhook_secret"] + + cond do + is_nil(store_id) -> + {:error, :no_store_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_credentials(api_key, store_id) do + url_with_token = append_token(webhook_url, secret) + Client.setup_webhooks(url_with_token, @webhook_events) + else + nil -> {:error, :no_api_key} + end + end + end + + @doc """ + Lists currently registered webhooks for this store. + """ + def list_webhooks(%ProviderConnection{config: config} = conn) do + store_id = config["store_id"] + + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_credentials(api_key, store_id) do + Client.get_webhooks() + else + nil -> {:error, :no_api_key} + end + end + + defp append_token(url, token) do + uri = URI.parse(url) + params = URI.decode_query(uri.query || "") + query = URI.encode_query(Map.put(params, "token", token)) + URI.to_string(%{uri | query: query}) + end + # ============================================================================= # Helpers # ============================================================================= diff --git a/lib/simpleshop_theme/webhooks.ex b/lib/simpleshop_theme/webhooks.ex index 6c9c0a5..817ba33 100644 --- a/lib/simpleshop_theme/webhooks.ex +++ b/lib/simpleshop_theme/webhooks.ex @@ -81,7 +81,85 @@ defmodule SimpleshopTheme.Webhooks do :ok end - # --- Private helpers --- + # ============================================================================= + # Printful events + # ============================================================================= + + @doc """ + Handles a Printful webhook event. + + Returns :ok or {:ok, result} on success, {:error, reason} on failure. + """ + + # --- Order events --- + + def handle_printful_event("package_shipped", data) do + with {:ok, order} <- find_printful_order(data) do + shipment = extract_printful_shipment(data) + + {:ok, updated} = + Orders.update_fulfilment(order, %{ + fulfilment_status: "shipped", + provider_status: "shipped", + tracking_number: shipment.tracking_number, + tracking_url: shipment.tracking_url, + shipped_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + OrderNotifier.deliver_shipping_notification(updated) + {:ok, updated} + end + end + + def handle_printful_event("order_failed", data) do + with {:ok, order} <- find_printful_order(data) do + Orders.update_fulfilment(order, %{ + fulfilment_status: "failed", + provider_status: "failed", + fulfilment_error: data["reason"] || "Order failed at Printful" + }) + end + end + + def handle_printful_event("order_canceled", data) do + with {:ok, order} <- find_printful_order(data) do + Orders.update_fulfilment(order, %{ + fulfilment_status: "cancelled", + provider_status: "canceled" + }) + end + end + + # --- Product events --- + + def handle_printful_event("product_updated", _data) do + enqueue_printful_sync() + end + + def handle_printful_event("product_synced", _data) do + enqueue_printful_sync() + end + + def handle_printful_event("product_deleted", %{"sync_product" => %{"id" => product_id}}) do + ProductDeleteWorker.enqueue(to_string(product_id)) + end + + def handle_printful_event("product_deleted", _data) do + enqueue_printful_sync() + end + + # --- Catch-all --- + + def handle_printful_event(event_type, _data) do + Logger.info("Ignoring unhandled Printful event: #{event_type}") + :ok + end + + # ============================================================================= + # Private helpers + # ============================================================================= + + # --- Printify helpers --- defp enqueue_product_sync do case Products.get_provider_connection_by_type("printify") do @@ -90,7 +168,6 @@ defmodule SimpleshopTheme.Webhooks do end end - # Printify order webhooks include external_id (our order_number) in the resource defp find_order_from_resource(%{"external_id" => external_id}) when is_binary(external_id) do case Orders.get_order_by_number(external_id) do nil -> @@ -116,4 +193,50 @@ defmodule SimpleshopTheme.Webhooks do tracking_url: shipment["tracking_url"] } end + + # --- Printful helpers --- + + defp enqueue_printful_sync do + case Products.get_provider_connection_by_type("printful") do + nil -> {:error, :no_connection} + conn -> ProductSyncWorker.enqueue(conn.id) + end + end + + # Printful order webhooks include external_id in the order data + defp find_printful_order(%{"order" => %{"external_id" => ext_id}}) + when is_binary(ext_id) and ext_id != "" do + find_order_by_external_id(ext_id) + end + + # Fallback: look for external_id at top level + defp find_printful_order(%{"external_id" => ext_id}) + when is_binary(ext_id) and ext_id != "" do + find_order_by_external_id(ext_id) + end + + defp find_printful_order(data) do + Logger.warning("Printful order webhook: can't find external_id in #{inspect(data)}") + {:error, :missing_external_id} + end + + defp find_order_by_external_id(external_id) do + case Orders.get_order_by_number(external_id) do + nil -> + Logger.warning("Order webhook: no order found for external_id=#{external_id}") + {:error, :order_not_found} + + order -> + {:ok, order} + end + end + + defp extract_printful_shipment(data) do + shipment = data["shipment"] || %{} + + %{ + tracking_number: shipment["tracking_number"], + tracking_url: shipment["tracking_url"] + } + end end diff --git a/lib/simpleshop_theme_web/controllers/webhook_controller.ex b/lib/simpleshop_theme_web/controllers/webhook_controller.ex index f2c2f1e..a6e5513 100644 --- a/lib/simpleshop_theme_web/controllers/webhook_controller.ex +++ b/lib/simpleshop_theme_web/controllers/webhook_controller.ex @@ -33,4 +33,34 @@ defmodule SimpleshopThemeWeb.WebhookController do json(conn, %{status: "ok"}) end end + + @doc """ + Receives Printful webhook events. + + Events: + - package_shipped - Package has been shipped + - order_failed - Order processing failed + - order_canceled - Order was canceled + - product_updated - Sync product was updated + - product_deleted - Sync product was deleted + """ + def printful(conn, params) do + event_type = params["type"] + data = params["data"] || %{} + + Logger.info("Received Printful webhook: #{event_type}") + + case Webhooks.handle_printful_event(event_type, data) do + :ok -> + json(conn, %{status: "ok"}) + + {:ok, _} -> + json(conn, %{status: "ok"}) + + {:error, reason} -> + Logger.warning("Printful webhook handling failed: #{inspect(reason)}") + # Return 200 to prevent Printful retrying + json(conn, %{status: "ok"}) + end + end end diff --git a/lib/simpleshop_theme_web/plugs/verify_printful_webhook.ex b/lib/simpleshop_theme_web/plugs/verify_printful_webhook.ex new file mode 100644 index 0000000..1ba4893 --- /dev/null +++ b/lib/simpleshop_theme_web/plugs/verify_printful_webhook.ex @@ -0,0 +1,67 @@ +defmodule SimpleshopThemeWeb.Plugs.VerifyPrintfulWebhook do + @moduledoc """ + Verifies Printful webhook requests using a shared secret token. + + Checks the `webhook_secret` stored in the Printful provider connection + config against the `X-PF-Webhook-Token` header (or `token` query param + as fallback). Can be upgraded to HMAC signature verification once the + exact Printful signing format is confirmed. + + Expects raw body cached in conn.assigns[:raw_body] (via CacheRawBody). + """ + + import Plug.Conn + require Logger + + alias SimpleshopTheme.Products + + def init(opts), do: opts + + def call(conn, _opts) do + with {:ok, token} <- get_token(conn), + {:ok, secret} <- get_webhook_secret(), + :ok <- verify_token(token, secret) do + conn + else + {:error, reason} -> + Logger.warning("Printful webhook verification failed: #{reason}") + + conn + |> put_resp_content_type("application/json") + |> send_resp(401, Jason.encode!(%{error: "Invalid token"})) + |> halt() + end + end + + defp get_token(conn) do + # Check header first, then query param + case get_req_header(conn, "x-pf-webhook-token") do + [token] when token != "" -> + {:ok, token} + + _ -> + case conn.query_params["token"] || conn.params["token"] do + token when is_binary(token) and token != "" -> {:ok, token} + _ -> {:error, :missing_token} + end + end + end + + defp get_webhook_secret do + case Products.get_provider_connection_by_type("printful") do + %{config: %{"webhook_secret" => secret}} when is_binary(secret) and secret != "" -> + {:ok, secret} + + _ -> + {:error, :no_webhook_secret} + end + end + + defp verify_token(token, secret) do + if Plug.Crypto.secure_compare(token, secret) do + :ok + else + {:error, :token_mismatch} + end + end +end diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index d7232b1..b80cb20 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -24,6 +24,10 @@ defmodule SimpleshopThemeWeb.Router do plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook end + pipeline :printful_webhook do + plug SimpleshopThemeWeb.Plugs.VerifyPrintfulWebhook + end + pipeline :shop do plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :shop_root} plug SimpleshopThemeWeb.Plugs.LoadTheme @@ -101,6 +105,12 @@ defmodule SimpleshopThemeWeb.Router do post "/printify", WebhookController, :printify end + scope "/webhooks", SimpleshopThemeWeb do + pipe_through [:api, :printful_webhook] + + post "/printful", WebhookController, :printful + end + scope "/webhooks", SimpleshopThemeWeb do pipe_through [:api] diff --git a/test/simpleshop_theme/webhooks_test.exs b/test/simpleshop_theme/webhooks_test.exs index e729e62..f4d79c9 100644 --- a/test/simpleshop_theme/webhooks_test.exs +++ b/test/simpleshop_theme/webhooks_test.exs @@ -132,4 +132,145 @@ defmodule SimpleshopTheme.WebhooksTest do }) end end + + # ============================================================================= + # Printful events + # ============================================================================= + + describe "handle_printful_event/2 — product events" do + setup do + conn = provider_connection_fixture(%{provider_type: "printful"}) + {:ok, printful_connection: conn} + end + + test "product_updated triggers sync", %{printful_connection: _conn} do + assert {:ok, %Oban.Job{}} = + Webhooks.handle_printful_event("product_updated", %{}) + end + + test "product_synced triggers sync", %{printful_connection: _conn} do + assert {:ok, %Oban.Job{}} = + Webhooks.handle_printful_event("product_synced", %{}) + end + + test "product_deleted with sync_product id triggers delete" do + assert {:ok, %Oban.Job{}} = + Webhooks.handle_printful_event("product_deleted", %{ + "sync_product" => %{"id" => 12345} + }) + end + + test "product_deleted without id triggers full sync" do + provider_connection_fixture(%{provider_type: "printful"}) + + assert {:ok, %Oban.Job{}} = + Webhooks.handle_printful_event("product_deleted", %{}) + end + + test "unknown event returns ok" do + assert :ok = Webhooks.handle_printful_event("stock_updated", %{}) + end + + test "returns error when no printful connection" do + # Delete the printful connection created in setup + import Ecto.Query + + from(pc in SimpleshopTheme.Products.ProviderConnection, + where: pc.provider_type == "printful" + ) + |> SimpleshopTheme.Repo.delete_all() + + assert {:error, :no_connection} = + Webhooks.handle_printful_event("product_updated", %{}) + end + end + + describe "handle_printful_event/2 — order events" do + setup do + provider_connection_fixture(%{provider_type: "printful"}) + :ok + end + + test "package_shipped sets tracking and shipped_at" do + {order, _v, _p, _c} = submitted_order_fixture() + + assert {:ok, updated} = + Webhooks.handle_printful_event("package_shipped", %{ + "order" => %{"external_id" => order.order_number}, + "shipment" => %{ + "tracking_number" => "PF-TRACK-001", + "tracking_url" => "https://tracking.printful.com/PF-TRACK-001" + } + }) + + assert updated.fulfilment_status == "shipped" + assert updated.tracking_number == "PF-TRACK-001" + assert updated.tracking_url == "https://tracking.printful.com/PF-TRACK-001" + assert updated.shipped_at != nil + end + + test "order_failed sets failed status" do + {order, _v, _p, _c} = submitted_order_fixture() + + assert {:ok, updated} = + Webhooks.handle_printful_event("order_failed", %{ + "order" => %{"external_id" => order.order_number}, + "reason" => "Out of stock" + }) + + assert updated.fulfilment_status == "failed" + assert updated.fulfilment_error == "Out of stock" + end + + test "order_failed with no reason uses default message" do + {order, _v, _p, _c} = submitted_order_fixture() + + assert {:ok, updated} = + Webhooks.handle_printful_event("order_failed", %{ + "order" => %{"external_id" => order.order_number} + }) + + assert updated.fulfilment_error == "Order failed at Printful" + end + + test "order_canceled sets cancelled status" do + {order, _v, _p, _c} = submitted_order_fixture() + + assert {:ok, updated} = + Webhooks.handle_printful_event("order_canceled", %{ + "order" => %{"external_id" => order.order_number} + }) + + assert updated.fulfilment_status == "cancelled" + end + + test "package_shipped with external_id at top level" do + {order, _v, _p, _c} = submitted_order_fixture() + + assert {:ok, updated} = + Webhooks.handle_printful_event("package_shipped", %{ + "external_id" => order.order_number, + "shipment" => %{ + "tracking_number" => "PF-TRACK-002" + } + }) + + assert updated.fulfilment_status == "shipped" + assert updated.tracking_number == "PF-TRACK-002" + end + + test "order event with unknown external_id returns error" do + assert {:error, :order_not_found} = + Webhooks.handle_printful_event("package_shipped", %{ + "order" => %{"external_id" => "SS-000000-NOPE"} + }) + end + + test "order event with no external_id returns error" do + assert {:error, :missing_external_id} = + Webhooks.handle_printful_event("package_shipped", %{ + "order" => %{"id" => 12345} + }) + end + end end diff --git a/test/simpleshop_theme_web/controllers/webhook_controller_test.exs b/test/simpleshop_theme_web/controllers/webhook_controller_test.exs index 30c1a2a..806c66e 100644 --- a/test/simpleshop_theme_web/controllers/webhook_controller_test.exs +++ b/test/simpleshop_theme_web/controllers/webhook_controller_test.exs @@ -98,4 +98,81 @@ defmodule SimpleshopThemeWeb.WebhookControllerTest do :crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16(case: :lower) end + + describe "POST /webhooks/printful" do + @printful_secret "printful_test_secret_456" + + setup do + _conn = + provider_connection_fixture(%{ + provider_type: "printful", + config: %{"store_id" => "99999", "webhook_secret" => @printful_secret} + }) + + :ok + end + + test "returns 401 without token", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/webhooks/printful", %{type: "product_updated"}) + + assert json_response(conn, 401)["error"] == "Invalid token" + end + + test "returns 401 with wrong token", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pf-webhook-token", "wrong_token") + |> post(~p"/webhooks/printful", %{type: "product_updated"}) + + assert json_response(conn, 401)["error"] == "Invalid token" + end + + test "accepts valid token via header", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pf-webhook-token", @printful_secret) + |> post(~p"/webhooks/printful", %{type: "product_updated", data: %{}}) + + assert json_response(conn, 200)["status"] == "ok" + end + + test "accepts valid token via query param", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/webhooks/printful?token=#{@printful_secret}", %{ + type: "product_updated", + data: %{} + }) + + assert json_response(conn, 200)["status"] == "ok" + end + + test "handles unknown event gracefully", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pf-webhook-token", @printful_secret) + |> post(~p"/webhooks/printful", %{type: "unknown_event", data: %{}}) + + assert json_response(conn, 200)["status"] == "ok" + end + + test "returns 401 when no webhook secret configured", %{conn: conn} do + SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("x-pf-webhook-token", @printful_secret) + |> post(~p"/webhooks/printful", %{type: "product_updated"}) + + assert json_response(conn, 401)["error"] == "Invalid token" + end + end end