From 51d9504f6b6eb4821e00971368006445eb36886e Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 31 Jan 2026 14:25:06 +0000 Subject: [PATCH] docs: add admin provider setup task and update project guidelines - add detailed task spec for /admin/providers UI with webhook integration - add product sync strategy with manual, webhook, and scheduled sync - update PROGRESS.md to prioritise admin provider UI as next task - add writing style guidelines (british english, sentence case, concise) - add commit guidelines (atomic, imperative, suggest at checkpoints) - add pragmatic testing guidelines (test boundaries, skip trivial) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 46 +++ PROGRESS.md | 15 +- docs/plans/products-context.md | 729 +++++++++++++++++++++++++++++++++ 3 files changed, 784 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0d86ba..96536f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,6 +170,52 @@ socket |> assign(form: to_form(changeset)) - Reference DOM IDs from templates in tests - Debug with LazyHTML: `LazyHTML.filter(document, "selector")` +## Writing Style + +- Casual, British English tone throughout (code, comments, commits, docs) +- Sentence case always, never title case ("Admin providers" not "Admin Providers") +- Brief and tight in prose. Cut fluff. No waffle. +- Avoid em dashes, semicolons in prose, and overly formal language +- Don't sound like an LLM wrote it (no "straightforward", "robust", "leverage", "comprehensive") +- Comments explain why, not what. Skip obvious ones. + +**Code style:** +- Explicit and obvious over clever and terse +- Readable code that anyone can follow beats "smart" one-liners +- Stick to idiomatic Phoenix/Elixir/LiveView/Oban patterns +- Follow conventions from the existing codebase +- When in doubt, check how Phoenix generators do it + +## Commits + +- Suggest a commit at logical checkpoints (feature complete, tests passing) +- Run `mix precommit` before committing +- Atomic commits with one logical change each +- Commit messages: imperative mood, lowercase, no full stop + - Good: `add provider connection form validation` + - Bad: `Added provider connection form validation.` + +## Testing + +Write tests for new features, but be pragmatic about coverage. + +**Do test:** +- Public context functions (the API boundary) +- Critical user flows (auth, checkout, sync) +- Edge cases and error handling +- Complex business logic + +**Skip tests for:** +- Trivial getters/setters +- Framework-generated code +- Pure UI tweaks with no logic +- Implementation details that may change + +**Approach:** +- Prefer integration tests over isolated unit tests for LiveViews +- One test file per module, colocated in `test/` mirror of `lib/` +- Use fixtures from `test/support/fixtures/` for test data + ## Documentation Workflow **Single source of truth:** [PROGRESS.md](PROGRESS.md) diff --git a/PROGRESS.md b/PROGRESS.md index 22fe12c..a75afa7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -16,9 +16,12 @@ ## Next Up -1. Wire Products context to shop LiveViews (replace PreviewData) -2. Add Printify product sync worker -3. Session-based cart with real variants +1. **Admin Provider Setup UI** (`/admin/providers`) - Add/edit/test provider connections +2. **Product Sync Strategy:** + - ProductSyncWorker (Oban) for manual/scheduled sync + - Webhook endpoint for real-time updates from Printify +3. Wire Products context to shop LiveViews (replace PreviewData) +4. Session-based cart with real variants --- @@ -56,11 +59,11 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im - [x] Product/variant/image schemas #### Remaining Tasks -- [ ] Add `list_products/1` to Printify client (~1hr) -- [ ] Create ProductSyncWorker (Oban) (~1hr) +- [ ] **Admin Provider Setup UI** - `/admin/providers` for managing connections (~2hr) ← **NEXT** +- [ ] Create ProductSyncWorker (Oban) for manual/scheduled sync (~1hr) +- [ ] Printify webhook endpoint for real-time product updates (~1.5hr) - [ ] Wire shop LiveViews to Products context (~2hr) - [ ] Add variant selector component (~2hr) -- [ ] Implement product sync mix task (~1hr) See: [docs/plans/products-context.md](docs/plans/products-context.md) for implementation details diff --git a/docs/plans/products-context.md b/docs/plans/products-context.md index 93dc3f3..0df2eea 100644 --- a/docs/plans/products-context.md +++ b/docs/plans/products-context.md @@ -1165,6 +1165,735 @@ end --- +--- + +## Task: Admin Provider Setup UI + +> **Status:** Next up +> **Estimate:** ~2 hours +> **Prerequisite:** Phase 1 complete (schemas, context, Printify provider) + +### Goal + +Add an admin UI at `/admin/providers` for managing POD provider connections. This enables shop owners to connect their Printify account and trigger product syncs without using IEx. + +### User Stories + +1. **Connect provider:** Enter Printify API key, test connection, save +2. **View status:** See connected providers, last sync time, product count +3. **Trigger sync:** Manually sync products from provider +4. **Disconnect:** Remove a provider connection + +### Routes + +```elixir +# In router.ex, within :require_authenticated_user live_session +live "/admin/providers", ProviderLive.Index, :index +live "/admin/providers/new", ProviderLive.Index, :new +live "/admin/providers/:id/edit", ProviderLive.Index, :edit +``` + +### Files to Create/Modify + +| File | Purpose | +|------|---------| +| `lib/simpleshop_theme_web/live/provider_live/index.ex` | LiveView for provider list + modal forms | +| `lib/simpleshop_theme_web/live/provider_live/index.html.heex` | Template | +| `lib/simpleshop_theme_web/live/provider_live/form_component.ex` | Form component for new/edit | +| `lib/simpleshop_theme/providers/printify.ex` | Add `register_webhooks/1`, `unregister_webhooks/1` | +| `lib/simpleshop_theme/workers/product_sync_worker.ex` | Stub for "Sync Now" (full impl in next task) | +| `test/simpleshop_theme_web/live/provider_live_test.exs` | LiveView tests | + +### UI Design + +Single-page admin with modal for add/edit (follows Phoenix generator pattern): + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Admin › Provider Connections [+ Add] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 🟢 Printify [Edit] [×] │ │ +│ │ "My Printify Shop" │ │ +│ │ Shop: Acme Store • 24 products │ │ +│ │ Last synced: 5 minutes ago │ │ +│ │ [Sync Now] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ⚪ Gelato [Edit] [×] │ │ +│ │ "Gelato Account" │ │ +│ │ Not connected (invalid API key) │ │ +│ │ [Sync Now] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Add/Edit Modal:** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Connect Printify [×] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Name │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ My Printify Shop │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ API Key │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ •••••••••••••••••••• │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ Get your API key from Printify Settings → Connections │ +│ │ +│ ┌──────────────────┐ │ +│ │ Test Connection │ ✓ Connected to "Acme Store" │ +│ └──────────────────┘ │ +│ │ +│ □ Enable automatic sync │ +│ │ +│ [Cancel] [Save Connection] │ +└─────────────────────────────────────────────────────────────┘ +``` + +### LiveView Implementation + +**Index LiveView (`provider_live/index.ex`):** + +```elixir +defmodule SimpleshopThemeWeb.ProviderLive.Index do + use SimpleshopThemeWeb, :live_view + + alias SimpleshopTheme.Products + alias SimpleshopTheme.Products.ProviderConnection + + @impl true + def mount(_params, _session, socket) do + connections = Products.list_provider_connections() + {:ok, stream(socket, :connections, connections)} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Provider") + |> assign(:connection, Products.get_provider_connection!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "Add Provider") + |> assign(:connection, %ProviderConnection{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Provider Connections") + |> assign(:connection, nil) + end + + @impl true + def handle_info({SimpleshopThemeWeb.ProviderLive.FormComponent, {:saved, connection}}, socket) do + {:noreply, stream_insert(socket, :connections, connection)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + connection = Products.get_provider_connection!(id) + {:ok, _} = Products.delete_provider_connection(connection) + {:noreply, stream_delete(socket, :connections, connection)} + end + + @impl true + def handle_event("sync", %{"id" => id}, socket) do + connection = Products.get_provider_connection!(id) + # Enqueue sync job (Oban worker) + {:ok, _job} = Products.enqueue_sync(connection) + + {:noreply, + socket + |> put_flash(:info, "Sync started for #{connection.name}") + |> stream_insert(:connections, %{connection | sync_status: "syncing"})} + end +end +``` + +**Form Component (`provider_live/form_component.ex`):** + +Key features: +- Provider type dropdown (Printify only initially, extensible) +- API key field (password type, shows masked on edit) +- "Test Connection" button with async feedback +- Validation before save +- Auto-registers webhooks on successful save + +```elixir +def handle_event("test_connection", _params, socket) do + form_data = socket.assigns.form.source.changes + api_key = form_data[:api_key] || socket.assigns.connection.api_key + + case test_provider_connection(socket.assigns.provider_type, api_key) do + {:ok, %{shop_name: name}} -> + {:noreply, assign(socket, test_result: {:ok, name})} + + {:error, reason} -> + {:noreply, assign(socket, test_result: {:error, reason})} + end +end + +defp test_provider_connection("printify", api_key) do + # Build temporary connection struct for testing + conn = %ProviderConnection{provider_type: "printify", api_key: api_key} + SimpleshopTheme.Providers.Printify.test_connection(conn) +end +``` + +### Events + +| Event | Handler | Action | +|-------|---------|--------| +| `"validate"` | FormComponent | Live validation of form fields | +| `"test_connection"` | FormComponent | Call provider's `test_connection/1`, show result | +| `"save"` | FormComponent | Create/update connection, register webhooks, notify parent | +| `"delete"` | Index | Unregister webhooks, delete connection | +| `"sync"` | Index | Enqueue `ProductSyncWorker` job | + +### Webhook Registration Flow + +When a provider connection is saved, automatically register webhooks with the provider so real-time sync works immediately. + +**On Save (FormComponent):** + +```elixir +def handle_event("save", %{"provider" => params}, socket) do + save_result = case socket.assigns.connection.id do + nil -> Products.create_provider_connection(params) + _id -> Products.update_provider_connection(socket.assigns.connection, params) + end + + case save_result do + {:ok, connection} -> + # Register webhooks after successful save + case register_webhooks(connection) do + {:ok, _} -> + Products.update_provider_connection(connection, %{ + config: Map.put(connection.config, "webhooks_registered", true) + }) + + {:error, reason} -> + # Log but don't fail - webhooks can be registered later + Logger.warning("Failed to register webhooks: #{inspect(reason)}") + end + + notify_parent({:saved, connection}) + {:noreply, + socket + |> put_flash(:info, "Provider connected successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end +end + +defp register_webhooks(%{provider_type: "printify"} = conn) do + SimpleshopTheme.Providers.Printify.register_webhooks(conn) +end +``` + +**On Delete (Index):** + +```elixir +def handle_event("delete", %{"id" => id}, socket) do + connection = Products.get_provider_connection!(id) + + # Unregister webhooks before deleting + unregister_webhooks(connection) + + {:ok, _} = Products.delete_provider_connection(connection) + {:noreply, stream_delete(socket, :connections, connection)} +end + +defp unregister_webhooks(%{provider_type: "printify"} = conn) do + SimpleshopTheme.Providers.Printify.unregister_webhooks(conn) +end +``` + +**Provider Webhook Functions (add to `Providers.Printify`):** + +```elixir +@webhook_topics ~w(product:publish:started product:deleted shop:disconnected) + +def register_webhooks(conn) do + webhook_url = SimpleshopThemeWeb.Endpoint.url() <> "/webhooks/printify" + shop_id = get_shop_id(conn) + + results = Enum.map(@webhook_topics, fn topic -> + Client.post(conn, "/shops/#{shop_id}/webhooks.json", %{ + topic: topic, + url: webhook_url + }) + end) + + if Enum.all?(results, &match?({:ok, _}, &1)) do + {:ok, :registered} + else + {:error, :partial_registration} + end +end + +def unregister_webhooks(conn) do + shop_id = get_shop_id(conn) + + # List existing webhooks and delete ours + case Client.get(conn, "/shops/#{shop_id}/webhooks.json") do + {:ok, %{"webhooks" => webhooks}} -> + our_url = SimpleshopThemeWeb.Endpoint.url() <> "/webhooks/printify" + + webhooks + |> Enum.filter(&(&1["url"] == our_url)) + |> Enum.each(&Client.delete(conn, "/shops/#{shop_id}/webhooks/#{&1["id"]}.json")) + + {:ok, :unregistered} + + error -> + error + end +end +``` + +### UI: Webhook Status Indicator + +Show webhook status on the connection card: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🟢 Printify [Edit] [×] │ +│ "My Printify Shop" │ +│ Shop: Acme Store • 24 products │ +│ Last synced: 5 minutes ago │ +│ ✓ Real-time updates enabled [Sync Now] │ ← webhook status +└─────────────────────────────────────────────────────────┘ +``` + +Or if webhooks failed to register: + +``` +│ ⚠ Real-time updates unavailable (click Sync manually) │ +``` + +Template snippet: +```heex +
+ <%= if @connection.config["webhooks_registered"] do %> + <.icon name="hero-check-circle" class="w-4 h-4 text-green-500" /> + Real-time updates enabled + <% else %> + <.icon name="hero-exclamation-triangle" class="w-4 h-4 text-amber-500" /> + Real-time updates unavailable + <% end %> +
+``` + +### Context Additions + +Add to `lib/simpleshop_theme/products.ex`: + +```elixir +@doc """ +Enqueues a product sync job for the given provider connection. +Returns {:ok, job} or {:error, changeset}. +""" +def enqueue_sync(%ProviderConnection{} = conn) do + %{connection_id: conn.id} + |> SimpleshopTheme.Workers.ProductSyncWorker.new() + |> Oban.insert() +end + +@doc """ +Gets product count for a provider connection. +""" +def count_products_for_connection(connection_id) do + from(p in Product, where: p.provider_connection_id == ^connection_id, select: count()) + |> Repo.one() +end +``` + +### Acceptance Criteria + +1. **Navigation:** `/admin` shows link to "Providers" in admin nav +2. **List view:** Shows all provider connections with status indicators +3. **Add flow:** Modal form with provider type, name, API key fields +4. **Test connection:** Button validates API key, shows shop name or error +5. **Save:** Creates encrypted connection, registers webhooks, closes modal, updates list +6. **Webhook status:** Card shows "Real-time updates enabled" or warning if registration failed +7. **Edit:** Pre-fills form (API key masked), allows updates, re-registers webhooks if key changed +8. **Delete:** Unregisters webhooks, confirmation dialog, removes connection +9. **Sync:** Button enqueues Oban job, shows "syncing" status +10. **Empty state:** Helpful message when no providers connected +11. **Auth:** Only accessible to authenticated admin users + +### Testing + +```elixir +# test/simpleshop_theme_web/live/provider_live_test.exs +describe "Index" do + setup :register_and_log_in_user + + test "lists all provider connections", %{conn: conn} do + connection = provider_connection_fixture() + {:ok, _lv, html} = live(conn, ~p"/admin/providers") + assert html =~ connection.name + end + + test "saves new provider connection", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/admin/providers/new") + + # Mock test_connection response + expect_test_connection_success() + + lv + |> form("#provider-form", provider: %{name: "Test", api_key: "key123"}) + |> render_submit() + + assert_patch(lv, ~p"/admin/providers") + assert render(lv) =~ "Test" + end + + test "deletes provider connection", %{conn: conn} do + connection = provider_connection_fixture() + {:ok, lv, _html} = live(conn, ~p"/admin/providers") + + lv |> element("#connection-#{connection.id} button", "Delete") |> render_click() + + refute render(lv) =~ connection.name + end +end +``` + +### Dependencies + +- **ProductSyncWorker:** Must exist (stub OK, full implementation separate task) +- **Providers.Printify.test_connection/1:** Already implemented +- **Providers.Printify.register_webhooks/1:** Implement as part of this task +- **Providers.Printify.unregister_webhooks/1:** Implement as part of this task + +### Out of Scope (Future Tasks) + +- Product list/management UI (`/admin/products`) +- Real-time sync progress updates (PubSub) +- OAuth flow for providers that support it +- Multiple connections per provider type + +--- + +## Task: Product Sync Strategy + +> **Status:** Planned +> **Estimate:** ~2.5 hours total +> **Prerequisite:** Admin Provider Setup UI + +### Goal + +Implement a robust product sync strategy with three mechanisms: +1. **Manual sync** - Admin clicks "Sync Now" (initial setup, recovery) +2. **Webhook sync** - Printify pushes updates in real-time (primary) +3. **Scheduled sync** - Daily fallback to catch missed webhooks (optional) + +### Printify Webhook Events + +| Event | Trigger | Action | +|-------|---------|--------| +| `product:publish:started` | Product published to shop | Fetch & upsert product | +| `product:deleted` | Product removed from shop | Archive product locally | +| `shop:disconnected` | API access revoked | Mark connection as disconnected | + +### Files to Create + +| File | Purpose | +|------|---------| +| `lib/simpleshop_theme/workers/product_sync_worker.ex` | Oban worker for full/single product sync | +| `lib/simpleshop_theme_web/controllers/webhook_controller.ex` | Receives webhooks from providers | +| `lib/simpleshop_theme/webhooks/printify_handler.ex` | Printify-specific webhook processing | +| `test/simpleshop_theme/workers/product_sync_worker_test.exs` | Worker tests | +| `test/simpleshop_theme_web/controllers/webhook_controller_test.exs` | Webhook endpoint tests | + +### Part 1: ProductSyncWorker (~1hr) + +Oban worker that syncs products from a provider connection. + +```elixir +defmodule SimpleshopTheme.Workers.ProductSyncWorker do + use Oban.Worker, + queue: :sync, + max_attempts: 3, + unique: [period: 60, fields: [:args, :queue]] + + alias SimpleshopTheme.Products + alias SimpleshopTheme.Providers + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"connection_id" => conn_id} = args}) do + conn = Products.get_provider_connection!(conn_id) + provider = Providers.for_type(conn.provider_type) + + Products.update_sync_status(conn, "syncing", nil) + + result = case args do + %{"product_id" => product_id} -> + # Single product sync (from webhook) + sync_single_product(conn, provider, product_id) + + _ -> + # Full sync (manual or scheduled) + sync_all_products(conn, provider) + end + + case result do + {:ok, stats} -> + Products.update_sync_status(conn, "completed", DateTime.utc_now()) + {:ok, stats} + + {:error, reason} -> + Products.update_sync_status(conn, "failed", nil) + {:error, reason} + end + end + + defp sync_all_products(conn, provider) do + case provider.fetch_products(conn) do + {:ok, products} -> + stats = Enum.reduce(products, %{synced: 0, failed: 0}, fn product, acc -> + case Products.upsert_product(conn, product) do + {:ok, _} -> %{acc | synced: acc.synced + 1} + {:error, _} -> %{acc | failed: acc.failed + 1} + end + end) + {:ok, stats} + + {:error, reason} -> + {:error, reason} + end + end + + defp sync_single_product(conn, provider, product_id) do + case provider.fetch_product(conn, product_id) do + {:ok, product} -> + Products.upsert_product(conn, product) + + {:error, :not_found} -> + # Product deleted on provider + Products.archive_product_by_provider(conn.id, product_id) + + {:error, reason} -> + {:error, reason} + end + end +end +``` + +**Job types:** +- Full sync: `%{connection_id: conn.id}` +- Single product: `%{connection_id: conn.id, product_id: "ext_123"}` +- Archive: `%{connection_id: conn.id, product_id: "ext_123", action: "archive"}` + +### Part 2: Webhook Endpoint (~1.5hr) + +**Route:** +```elixir +# In router.ex (outside auth scopes - public endpoint) +post "/webhooks/printify", WebhookController, :printify +``` + +**Controller:** +```elixir +defmodule SimpleshopThemeWeb.WebhookController do + use SimpleshopThemeWeb, :controller + + alias SimpleshopTheme.Webhooks.PrintifyHandler + + def printify(conn, params) do + with :ok <- verify_printify_signature(conn), + :ok <- PrintifyHandler.handle(params) do + json(conn, %{status: "ok"}) + else + {:error, :invalid_signature} -> + conn |> put_status(401) |> json(%{error: "Invalid signature"}) + + {:error, reason} -> + conn |> put_status(422) |> json(%{error: reason}) + end + end + + defp verify_printify_signature(conn) do + # Printify signs webhooks with HMAC-SHA256 + # Header: X-Printify-Signature + signature = get_req_header(conn, "x-printify-signature") |> List.first() + body = conn.assigns[:raw_body] + secret = Application.get_env(:simpleshop_theme, :printify_webhook_secret) + + expected = :crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16(case: :lower) + + if Plug.Crypto.secure_compare(signature || "", expected) do + :ok + else + {:error, :invalid_signature} + end + end +end +``` + +**Handler:** +```elixir +defmodule SimpleshopTheme.Webhooks.PrintifyHandler do + alias SimpleshopTheme.Products + alias SimpleshopTheme.Workers.ProductSyncWorker + + def handle(%{"type" => "product:publish:started", "resource" => resource}) do + %{"shop_id" => shop_id, "id" => product_id} = resource + + with {:ok, conn} <- find_connection_by_shop(shop_id) do + %{connection_id: conn.id, product_id: to_string(product_id)} + |> ProductSyncWorker.new() + |> Oban.insert() + end + end + + def handle(%{"type" => "product:deleted", "resource" => resource}) do + %{"shop_id" => shop_id, "id" => product_id} = resource + + with {:ok, conn} <- find_connection_by_shop(shop_id) do + Products.archive_product_by_provider(conn.id, to_string(product_id)) + end + end + + def handle(%{"type" => "shop:disconnected", "resource" => %{"shop_id" => shop_id}}) do + with {:ok, conn} <- find_connection_by_shop(shop_id) do + Products.update_provider_connection(conn, %{enabled: false, sync_status: "disconnected"}) + end + end + + def handle(%{"type" => type}) do + # Log unknown webhook types but don't fail + Logger.info("Unhandled Printify webhook: #{type}") + :ok + end + + defp find_connection_by_shop(shop_id) do + case Products.get_provider_connection_by_shop_id("printify", shop_id) do + nil -> {:error, :connection_not_found} + conn -> {:ok, conn} + end + end +end +``` + +### Part 3: Scheduled Sync (Optional) + +Add to Oban config for daily fallback: +```elixir +# In config/config.exs +config :simpleshop_theme, Oban, + plugins: [ + {Oban.Plugins.Cron, crontab: [ + {"0 3 * * *", SimpleshopTheme.Workers.ScheduledSyncWorker} # 3 AM daily + ]} + ] +``` + +```elixir +defmodule SimpleshopTheme.Workers.ScheduledSyncWorker do + use Oban.Worker, queue: :sync + + def perform(_job) do + Products.list_provider_connections(enabled: true) + |> Enum.each(fn conn -> + %{connection_id: conn.id} + |> ProductSyncWorker.new(schedule_in: Enum.random(0..300)) # Stagger over 5 min + |> Oban.insert() + end) + + :ok + end +end +``` + +### Context Additions + +Add to `lib/simpleshop_theme/products.ex`: + +```elixir +def archive_product_by_provider(connection_id, provider_product_id) do + case get_product_by_provider(connection_id, provider_product_id) do + nil -> {:ok, :not_found} + product -> update_product(product, %{status: "archived", visible: false}) + end +end + +def get_provider_connection_by_shop_id(provider_type, shop_id) do + from(c in ProviderConnection, + where: c.provider_type == ^provider_type, + where: fragment("json_extract(config, '$.shop_id') = ?", ^shop_id) + ) + |> Repo.one() +end +``` + +### Webhook Registration + +Webhook registration with Printify is handled by the Admin Provider Setup UI task: +- **On save:** `Providers.Printify.register_webhooks/1` is called +- **On delete:** `Providers.Printify.unregister_webhooks/1` cleans up + +See the "Webhook Registration Flow" section in the Admin Provider Setup UI task for implementation details. + +**Note:** Webhooks require a public URL. For local dev, use ngrok or similar: +```bash +ngrok http 4000 +# Then set WEBHOOK_URL=https://abc123.ngrok.io in .env +``` + +### Acceptance Criteria + +1. **Manual sync:** "Sync Now" button enqueues ProductSyncWorker, products appear +2. **Webhook received:** POST to `/webhooks/printify` with valid signature succeeds +3. **Invalid signature:** Returns 401, no job enqueued +4. **Product published:** Webhook triggers single-product sync +5. **Product deleted:** Webhook archives product locally +6. **Shop disconnected:** Connection marked as disabled +7. **Scheduled sync:** (If implemented) Runs daily, syncs all enabled connections + +### Testing + +```elixir +describe "WebhookController.printify/2" do + test "rejects invalid signature" do + conn = post(build_conn(), ~p"/webhooks/printify", %{type: "product:deleted"}) + assert json_response(conn, 401)["error"] == "Invalid signature" + end + + test "handles product:publish:started" do + conn = provider_connection_fixture(config: %{"shop_id" => "12345"}) + + payload = %{"type" => "product:publish:started", "resource" => %{"shop_id" => "12345", "id" => "prod_1"}} + + conn = post(signed_conn(payload), ~p"/webhooks/printify", payload) + assert json_response(conn, 200) + + assert_enqueued(worker: ProductSyncWorker, args: %{connection_id: conn.id, product_id: "prod_1"}) + end +end +``` + +--- + ## Verification (Manual) - Create Printify connection with API key - `Products.test_provider_connection(conn)` returns shop info