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:
parent
336b2bb81d
commit
51d9504f6b
46
CLAUDE.md
46
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)
|
||||
|
||||
15
PROGRESS.md
15
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
|
||||
|
||||
|
||||
@ -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)
|
||||
- Create Printify connection with API key
|
||||
- `Products.test_provider_connection(conn)` returns shop info
|
||||
|
||||
Loading…
Reference in New Issue
Block a user