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
|
- 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)
|
||||||
|
|||||||
15
PROGRESS.md
15
PROGRESS.md
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user