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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-01-31 14:25:06 +00:00
parent 336b2bb81d
commit 51d9504f6b
3 changed files with 784 additions and 6 deletions

View File

@ -170,6 +170,52 @@ socket |> assign(form: to_form(changeset))
- Reference DOM IDs from templates in tests - Reference DOM IDs from templates in tests
- Debug with LazyHTML: `LazyHTML.filter(document, "selector")` - 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 ## Documentation Workflow
**Single source of truth:** [PROGRESS.md](PROGRESS.md) **Single source of truth:** [PROGRESS.md](PROGRESS.md)

View File

@ -16,9 +16,12 @@
## Next Up ## Next Up
1. Wire Products context to shop LiveViews (replace PreviewData) 1. **Admin Provider Setup UI** (`/admin/providers`) - Add/edit/test provider connections
2. Add Printify product sync worker 2. **Product Sync Strategy:**
3. Session-based cart with real variants - 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 - [x] Product/variant/image schemas
#### Remaining Tasks #### Remaining Tasks
- [ ] Add `list_products/1` to Printify client (~1hr) - [ ] **Admin Provider Setup UI** - `/admin/providers` for managing connections (~2hr) ← **NEXT**
- [ ] Create ProductSyncWorker (Oban) (~1hr) - [ ] 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) - [ ] Wire shop LiveViews to Products context (~2hr)
- [ ] Add variant selector component (~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 See: [docs/plans/products-context.md](docs/plans/products-context.md) for implementation details

View File

@ -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
<div class="text-sm text-gray-500">
<%= 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 %>
</div>
```
### 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) ## Verification (Manual)
- Create Printify connection with API key - Create Printify connection with API key
- `Products.test_provider_connection(conn)` returns shop info - `Products.test_provider_connection(conn)` returns shop info