- 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>
1915 lines
83 KiB
Markdown
1915 lines
83 KiB
Markdown
# Plan: Products Context with Provider Integration
|
||
|
||
> **Status:** Phase 1 Complete (c5c06d9) - See [PROGRESS.md](../../PROGRESS.md) for current status.
|
||
|
||
## Goal
|
||
Build a Products context that syncs products from external POD providers (Printify first), stores them locally, and enables order submission for fulfillment.
|
||
|
||
## Core Flow
|
||
1. **Connect** - Authenticate with provider (API key or OAuth)
|
||
2. **Sync** - Fetch products from provider, upsert locally
|
||
3. **Order** - Submit orders to provider for fulfillment
|
||
|
||
---
|
||
|
||
## Current Domain Analysis
|
||
|
||
SimpleShop has **6 well-defined domains** with clear boundaries:
|
||
|
||
| Domain | Purpose | Schemas | Public Functions |
|
||
|--------|---------|---------|------------------|
|
||
| **Accounts** | User authentication & sessions | User, UserToken | 15 |
|
||
| **Settings** | Theme configuration persistence | Setting, ThemeSettings | 6 |
|
||
| **Theme** | CSS generation, caching, presets | (structs only) | N/A |
|
||
| **Media** | Image upload/storage | Image | 8 |
|
||
| **Images** | Image optimization pipeline | (Oban workers) | N/A |
|
||
| **Printify** | External API integration | (no schemas) | API client |
|
||
|
||
The new **Products** context will be a new top-level domain that:
|
||
- Owns product/variant schemas
|
||
- Coordinates with Printify (and future providers) for sync
|
||
- Provides data to shop LiveViews (replacing PreviewData)
|
||
|
||
---
|
||
|
||
## Architecture Diagram
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ SimpleshopTheme │
|
||
├─────────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ WEB LAYER │ │
|
||
│ │ SimpleshopThemeWeb │ │
|
||
│ │ ┌────────────────┐ ┌────────────────┐ ┌─────────────────────────┐ │ │
|
||
│ │ │ Shop LiveViews │ │ Admin LiveViews│ │ Theme Editor LiveView │ │ │
|
||
│ │ │ - ProductShow │ │ - UserLogin │ │ - ThemeLive.Index │ │ │
|
||
│ │ │ - Collection │ │ - UserSettings │ │ - Preview iframe │ │ │
|
||
│ │ │ - Cart │ │ │ │ │ │ │
|
||
│ │ └───────┬─────────┘ └────────────────┘ └────────┬───────────────┘ │ │
|
||
│ └──────────┼────────────────────────────────────────┼──────────────────┘ │
|
||
│ │ │ │
|
||
│ ▼ ▼ │
|
||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ DOMAIN LAYER │ │
|
||
│ │ │ │
|
||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
||
│ │ │ Accounts │ │ Settings │ │ Theme │ │ │
|
||
│ │ │ │ │ │ │ │ │ │
|
||
│ │ │ • User │ │ • Setting │ │ • CSSCache │ │ │
|
||
│ │ │ • UserToken │ │ • Theme │ │ • CSSGen │ │ │
|
||
│ │ │ │ │ Settings │ │ • Presets │ │ │
|
||
│ │ │ │ │ │ │ • Fonts │ │ │
|
||
│ │ │ │ │ │ │ • Preview │ │ │
|
||
│ │ └─────────────┘ └──────┬──────┘ │ Data (*) │ │ │
|
||
│ │ │ └─────────────┘ │ │
|
||
│ │ │ │ │
|
||
│ │ ┌─────────────┐ ┌──────▼──────┐ ┌─────────────┐ │ │
|
||
│ │ │ Media │ │ Images │ │ Printify │ │ │
|
||
│ │ │ │ │ │ │ │ │ │
|
||
│ │ │ • Image │◄──│ • Optimizer │ │ • Client │ │ │
|
||
│ │ │ (upload) │ │ • Worker │ │ • Catalog │ (NEW) │ │
|
||
│ │ │ │ │ • Cache │ │ • Mockup │ │ │
|
||
│ │ └─────────────┘ └─────────────┘ └──────┬──────┘ │ │
|
||
│ │ │ │ │
|
||
│ │ ┌───────────────────────────────────────────▼───────────────────┐ │ │
|
||
│ │ │ Products (NEW) │ │ │
|
||
│ │ │ │ │ │
|
||
│ │ │ ┌─────────────────────┐ ┌────────────────────────────┐ │ │ │
|
||
│ │ │ │ Provider Layer │ │ Product Layer │ │ │ │
|
||
│ │ │ │ │ │ │ │ │ │
|
||
│ │ │ │ • ProviderConnection│───▶│ • Product │ │ │ │
|
||
│ │ │ │ • Provider behaviour│ │ • ProductImage │ │ │ │
|
||
│ │ │ │ • PrintifyProvider │ │ • ProductVariant │ │ │ │
|
||
│ │ │ │ • (future providers)│ │ │ │ │ │
|
||
│ │ │ └─────────────────────┘ └────────────────────────────┘ │ │ │
|
||
│ │ │ │ │ │
|
||
│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │
|
||
│ │ │ │ Sync Layer │ │ │ │
|
||
│ │ │ │ • ProductSyncWorker (Oban) │ │ │ │
|
||
│ │ │ │ • Upsert logic │ │ │ │
|
||
│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │
|
||
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
||
│ │ │ │
|
||
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
|
||
│ │ │ Orders (NEW) │ │ │
|
||
│ │ │ │ │ │
|
||
│ │ │ • Order • submit_to_provider/1 │ │ │
|
||
│ │ │ • OrderLineItem • track_status/1 │ │ │
|
||
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
||
│ │ │ │
|
||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ INFRASTRUCTURE │ │
|
||
│ │ Repo (SQLite) • Vault (encryption) • Oban (background jobs) │ │
|
||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
|
||
(*) PreviewData will be replaced by Products context queries
|
||
```
|
||
|
||
---
|
||
|
||
## Database ERD
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ CURRENT SCHEMA │
|
||
├─────────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||
│ │ users │ │ users_tokens │ │
|
||
│ ├──────────────────┤ ├──────────────────┤ │
|
||
│ │ id (binary_id) │◄────────│ user_id (FK) │ │
|
||
│ │ email │ │ token │ │
|
||
│ │ hashed_password │ │ context │ │
|
||
│ │ confirmed_at │ │ sent_to │ │
|
||
│ │ inserted_at │ │ inserted_at │ │
|
||
│ │ updated_at │ └──────────────────┘ │
|
||
│ └──────────────────┘ │
|
||
│ │
|
||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||
│ │ settings │ │ images │ │
|
||
│ ├──────────────────┤ ├──────────────────┤ │
|
||
│ │ id (binary_id) │ │ id (binary_id) │ │
|
||
│ │ key │ │ filename │ │
|
||
│ │ value (JSON) │ │ content_type │ │
|
||
│ │ inserted_at │ │ byte_size │ │
|
||
│ │ updated_at │ │ width │ │
|
||
│ └──────────────────┘ │ height │ │
|
||
│ │ storage_key │ │
|
||
│ │ inserted_at │ │
|
||
│ │ updated_at │ │
|
||
│ └──────────────────┘ │
|
||
├─────────────────────────────────────────────────────────────────────────────┤
|
||
│ NEW SCHEMA (Products) │
|
||
├─────────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌────────────────────────┐ │
|
||
│ │ provider_connections │ │
|
||
│ ├────────────────────────┤ │
|
||
│ │ id (binary_id) │ │
|
||
│ │ provider_type │──┐ "printify" | "prodigi" | "gelato" │
|
||
│ │ name │ │ │
|
||
│ │ enabled │ │ │
|
||
│ │ api_key_encrypted │ │ (Vault encrypted) │
|
||
│ │ config (JSON) │ │ {"shop_id": "123"} │
|
||
│ │ last_synced_at │ │ │
|
||
│ │ sync_status │ │ │
|
||
│ │ inserted_at │ │ │
|
||
│ │ updated_at │ │ │
|
||
│ └───────────┬────────────┘ │ │
|
||
│ │ │ │
|
||
│ │ 1 │ │
|
||
│ ▼ * │ │
|
||
│ ┌────────────────────────┐ │ │
|
||
│ │ products │ │ │
|
||
│ ├────────────────────────┤ │ │
|
||
│ │ id (binary_id) │ │ │
|
||
│ │ provider_connection_id │◄─┘ │
|
||
│ │ provider_product_id │ External ID (unique per connection) │
|
||
│ │ title │ │
|
||
│ │ description │ │
|
||
│ │ slug │ URL-friendly (unique globally) │
|
||
│ │ status │ "active" | "draft" | "archived" │
|
||
│ │ visible │ │
|
||
│ │ category │ │
|
||
│ │ option_types (JSON) │ ["size", "color"] - display order │
|
||
│ │ provider_data (JSON) │ Provider-specific fields (blueprint_id etc) │
|
||
│ │ inserted_at │ │
|
||
│ │ updated_at │ │
|
||
│ └───────────┬────────────┘ │
|
||
│ │ │
|
||
│ │ 1 │
|
||
│ ┌─────────┴─────────┐ │
|
||
│ ▼ * ▼ * │
|
||
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||
│ │ product_images │ │ product_variants │ │
|
||
│ ├──────────────────┤ ├──────────────────────┤ │
|
||
│ │ id (binary_id) │ │ id (binary_id) │ │
|
||
│ │ product_id (FK) │ │ product_id (FK) │ │
|
||
│ │ src │ │ provider_variant_id │ │
|
||
│ │ position │ │ title │ "Large / Black" │
|
||
│ │ alt │ │ sku │ │
|
||
│ │ inserted_at │ │ price │ Selling price (pence) │
|
||
│ │ updated_at │ │ compare_at_price │ For sale display │
|
||
│ └──────────────────┘ │ cost │ Provider cost (profit) │
|
||
│ │ options (JSON) │ {"size": "L", "color": ""} │
|
||
│ │ is_enabled │ │
|
||
│ │ is_available │ │
|
||
│ │ inserted_at │ │
|
||
│ │ updated_at │ │
|
||
│ └──────────────────────┘ │
|
||
│ │
|
||
├─────────────────────────────────────────────────────────────────────────────┤
|
||
│ NEW SCHEMA (Orders) │
|
||
│ │
|
||
│ Customer sees ONE order. Fulfillments are internal (one per provider). │
|
||
├─────────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌────────────────────────┐ │
|
||
│ │ orders │ Customer-facing order │
|
||
│ ├────────────────────────┤ │
|
||
│ │ id (binary_id) │ │
|
||
│ │ order_number │ "SS-250125-A1B2" (customer sees this) │
|
||
│ │ status │ pending → processing → shipped → delivered │
|
||
│ │ payment_status │ pending | paid | refunded │
|
||
│ │ customer_email │ │
|
||
│ │ shipping_address (JSON)│ │
|
||
│ │ subtotal │ (Money - ex_money) │
|
||
│ │ shipping_cost │ (Money - ex_money) │
|
||
│ │ total │ (Money - ex_money) │
|
||
│ │ total_cost │ Provider costs (for profit) │
|
||
│ │ inserted_at │ │
|
||
│ │ updated_at │ │
|
||
│ └───────────┬────────────┘ │
|
||
│ │ │
|
||
│ │ 1 │
|
||
│ ┌─────────┴─────────────────────────┐ │
|
||
│ ▼ * ▼ * │
|
||
│ ┌────────────────────────┐ ┌─────────────────────────┐ │
|
||
│ │ order_fulfillments │ │ order_line_items │ │
|
||
│ ├────────────────────────┤ ├─────────────────────────┤ │
|
||
│ │ id (binary_id) │ │ id (binary_id) │ │
|
||
│ │ order_id (FK) │ │ order_id (FK) │ │
|
||
│ │ provider_connection_id │ │ order_fulfillment_id(FK)│ Which provider │
|
||
│ │ provider_order_id │ │ product_variant_id (FK) │ │
|
||
│ │ status │ │ quantity │ │
|
||
│ │ tracking_number │ │ unit_price │ │
|
||
│ │ tracking_url │ │ unit_cost │ │
|
||
│ │ shipped_at │ │ inserted_at │ │
|
||
│ │ inserted_at │ │ updated_at │ │
|
||
│ │ updated_at │ └─────────────────────────┘ │
|
||
│ └────────────────────────┘ │
|
||
│ │
|
||
│ ┌────────────────────────┐ │
|
||
│ │ order_events │ Timeline visible to customer │
|
||
│ ├────────────────────────┤ │
|
||
│ │ id (binary_id) │ │
|
||
│ │ order_id (FK) │ │
|
||
│ │ type │ "placed" | "paid" | "shipped" | "delivered" │
|
||
│ │ message │ "Your T-Shirt has shipped via Royal Mail" │
|
||
│ │ metadata (JSON) │ {tracking_number, carrier, etc} │
|
||
│ │ inserted_at │ │
|
||
│ └────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Printify Code Reuse
|
||
|
||
### Existing Client (Highly Reusable)
|
||
|
||
The existing `Printify.Client` is directly reusable:
|
||
|
||
```elixir
|
||
# HTTP infrastructure (no changes needed)
|
||
Client.get/2, Client.post/3, Client.delete/2
|
||
|
||
# Shop management
|
||
Client.get_shops/0, Client.get_shop_id/0
|
||
|
||
# Product operations (already implemented)
|
||
Client.get_product/2, Client.create_product/2, Client.delete_product/2
|
||
```
|
||
|
||
**To add:**
|
||
```elixir
|
||
Client.list_products/1 # GET /shops/:shop_id/products.json
|
||
Client.create_order/2 # POST /shops/:shop_id/orders.json
|
||
Client.get_order/2 # GET /shops/:shop_id/orders/:id.json
|
||
```
|
||
|
||
### MockupGenerator Refactoring
|
||
|
||
Extract reusable helpers into new `Printify.Catalog` module:
|
||
|
||
| Function | Current | Target |
|
||
|----------|---------|--------|
|
||
| `find_blueprint/1` | MockupGenerator | `Printify.Catalog` |
|
||
| `find_print_provider/2` | MockupGenerator | `Printify.Catalog` |
|
||
| `get_variant_info/1` | MockupGenerator | `Printify.Catalog` |
|
||
| `calculate_cover_scale/2` | MockupGenerator | Keep (mockup-specific) |
|
||
| `product_definitions/0` | MockupGenerator | Keep (sample data) |
|
||
|
||
**Module structure:**
|
||
```
|
||
lib/simpleshop_theme/clients/
|
||
├── printify.ex # Printify HTTP client (moved from printify/client.ex)
|
||
├── gelato.ex # Gelato HTTP client
|
||
└── prodigi.ex # Prodigi HTTP client
|
||
|
||
lib/simpleshop_theme/providers/
|
||
├── provider.ex # Behaviour definition
|
||
├── printify.ex # Printify implementation (uses Clients.Printify)
|
||
├── gelato.ex # Gelato implementation (uses Clients.Gelato)
|
||
└── prodigi.ex # Prodigi implementation (uses Clients.Prodigi)
|
||
|
||
lib/simpleshop_theme/mockups/
|
||
└── generator.ex # Mockup generation (currently uses Clients.Printify)
|
||
# Provider-agnostic location for future flexibility
|
||
|
||
lib/simpleshop_theme/printify/
|
||
└── catalog.ex # Blueprint/variant discovery helpers (Printify-specific)
|
||
```
|
||
|
||
Each provider module uses its corresponding client. The mockup generator is in a provider-agnostic location.
|
||
|
||
---
|
||
|
||
## Database Schema
|
||
|
||
### Tables Overview
|
||
|
||
| Table | Purpose |
|
||
|-------|---------|
|
||
| `provider_connections` | Provider credentials + config (one per type) |
|
||
| `products` | Core product data synced from provider |
|
||
| `product_images` | Product images (multiple per product) |
|
||
| `product_variants` | Size/color variants with pricing |
|
||
| `orders` | Customer orders |
|
||
| `order_line_items` | Items within orders |
|
||
|
||
### Key Design Decisions
|
||
|
||
1. **Provider IDs on product directly** (not a separate linking table)
|
||
- `products.provider_product_id` stores external ID
|
||
- `product_variants.provider_variant_id` stores external variant ID
|
||
- Unique constraint: `[:provider_connection_id, :provider_product_id]`
|
||
|
||
2. **Credentials encrypted in database**
|
||
- Use `SimpleshopTheme.Vault` for at-rest encryption
|
||
- `api_key_encrypted`, `oauth_access_token_encrypted` fields
|
||
|
||
3. **Cost tracking for profit calculation**
|
||
- `product_variants.cost` - cost from provider
|
||
- `order_line_items.unit_cost` - snapshot at order time
|
||
- `orders.total_cost` - sum for profit reporting
|
||
|
||
4. **Provider type as enum** (not separate table)
|
||
- Hardcoded: `~w(printify prodigi gelato)`
|
||
- Keeps type safety for provider-specific logic
|
||
|
||
---
|
||
|
||
## Schema Details
|
||
|
||
### provider_connections
|
||
```elixir
|
||
field :provider_type, :string # "printify", "prodigi", "gelato"
|
||
field :name, :string # "My Printify Account"
|
||
field :enabled, :boolean
|
||
field :api_key_encrypted, :binary # Encrypted API key
|
||
field :config, :map # {"shop_id": "12345"}
|
||
field :last_synced_at, :utc_datetime
|
||
field :sync_status, :string # pending, syncing, completed, failed
|
||
```
|
||
|
||
### products
|
||
```elixir
|
||
belongs_to :provider_connection
|
||
field :provider_product_id, :string # External ID from provider
|
||
field :title, :string
|
||
field :description, :text
|
||
field :slug, :string # URL-friendly, unique
|
||
field :status, :string # active, draft, archived
|
||
field :visible, :boolean
|
||
field :category, :string
|
||
field :option_types, {:array, :string} # ["Colors", "Sizes"] - option names in display order
|
||
# Extracted from provider's product.options[].name on sync
|
||
# Used to order the variant selector UI
|
||
field :provider_data, :map # Provider-specific fields stored here
|
||
# Printify: %{"blueprint_id" => 145, "print_provider_id" => 29, ...}
|
||
# Keeps products table generic across providers
|
||
```
|
||
|
||
### product_variants
|
||
```elixir
|
||
belongs_to :product
|
||
field :provider_variant_id, :string # CRITICAL: The ID we send back to provider when ordering
|
||
# Printify: variant_id (e.g., "12345")
|
||
# Printful: sync_variant_id (e.g., "4752058849")
|
||
# Gelato: productUid (e.g., "apparel_product_gca_t-shirt_gsi_m_gco_white")
|
||
# Prodigi: sku (e.g., "GLOBAL-CAN-10x10")
|
||
field :title, :string # "Large / Black"
|
||
field :sku, :string # Shop's internal SKU (optional)
|
||
field :price, :integer # Selling price (pence)
|
||
field :compare_at_price, :integer # Original price (for sales)
|
||
field :cost, :integer # Provider cost (for profit)
|
||
field :options, :map # {"size": "Large", "color": "Black"}
|
||
# JSON is appropriate here because:
|
||
# - Options always loaded with variant (no join needed)
|
||
# - No need to query "find all Large variants"
|
||
# - Matches provider data format (easy sync)
|
||
# - Simpler than a separate options table
|
||
field :is_enabled, :boolean
|
||
field :is_available, :boolean # In stock at provider
|
||
```
|
||
|
||
### orders (customer-facing)
|
||
```elixir
|
||
# Customer sees ONE order, even if fulfilled by multiple providers
|
||
field :order_number, :string # "SS-250125-A1B2" (customer-visible)
|
||
field :status, :string # Aggregate: pending → processing → shipped → delivered
|
||
field :payment_status, :string # pending, paid, refunded
|
||
field :customer_email, :string
|
||
field :shipping_address, :map
|
||
field :subtotal, Money.Ecto.Map.Type # Using ex_money for currency safety
|
||
field :shipping_cost, Money.Ecto.Map.Type
|
||
field :total, Money.Ecto.Map.Type
|
||
field :total_cost, Money.Ecto.Map.Type # Sum of provider costs
|
||
|
||
has_many :order_fulfillments # One per provider
|
||
has_many :order_line_items
|
||
has_many :order_events # Timeline for customer
|
||
```
|
||
|
||
### order_fulfillments (one per provider)
|
||
```elixir
|
||
belongs_to :order
|
||
belongs_to :provider_connection
|
||
field :provider_order_id, :string # External ID after submission
|
||
field :status, :string # pending → submitted → processing → shipped
|
||
field :tracking_number, :string
|
||
field :tracking_url, :string
|
||
field :shipped_at, :utc_datetime
|
||
|
||
has_many :order_line_items # Items fulfilled by this provider
|
||
```
|
||
|
||
### order_line_items
|
||
```elixir
|
||
belongs_to :order
|
||
belongs_to :order_fulfillment # Links to which provider ships this item
|
||
belongs_to :product_variant
|
||
field :quantity, :integer
|
||
field :unit_price, Money.Ecto.Map.Type
|
||
field :unit_cost, Money.Ecto.Map.Type
|
||
|
||
# Per-item tracking (provider may ship items separately)
|
||
field :status, :string # pending → shipped → delivered
|
||
field :tracking_number, :string
|
||
field :tracking_url, :string
|
||
field :shipped_at, :utc_datetime
|
||
```
|
||
|
||
### order_events (customer timeline)
|
||
```elixir
|
||
belongs_to :order
|
||
field :type, :string # Event types (see below)
|
||
field :message, :string # "Your T-Shirt and Mug have shipped!"
|
||
field :metadata, :map # {tracking_number, carrier, items, etc}
|
||
|
||
# Event types:
|
||
# - "placed" → Order placed
|
||
# - "paid" → Payment confirmed
|
||
# - "fulfillment_shipped" → Some items shipped (granular per-fulfillment)
|
||
# - "shipped" → ALL items now shipped
|
||
# - "delivered" → ALL items delivered
|
||
|
||
# Example metadata for fulfillment_shipped:
|
||
# %{fulfillment_id: "uuid", items: ["T-Shirt", "Mug"],
|
||
# carrier: "Royal Mail", tracking_number: "RM123", tracking_url: "..."}
|
||
```
|
||
|
||
---
|
||
|
||
## Variant Selector UX Pattern
|
||
|
||
Products with multiple option types (e.g., size × color × sleeves) can have many variants:
|
||
- 4 sizes × 4 colors × 2 sleeves = **32 variants**
|
||
|
||
### Data Model
|
||
|
||
Each variant stores its specific combination as JSON with **human-readable labels**:
|
||
```elixir
|
||
%{id: "v1", options: %{"size" => "M", "color" => "Black", "sleeves" => "Short"}, price: 2500}
|
||
%{id: "v2", options: %{"size" => "M", "color" => "Black", "sleeves" => "Long"}, price: 2700}
|
||
# ... etc
|
||
```
|
||
|
||
### Provider Option Structure (Important)
|
||
|
||
Providers like Printify use a **two-tier option system**:
|
||
|
||
**Product level** - defines options with IDs and labels:
|
||
```json
|
||
{
|
||
"options": [
|
||
{ "name": "Colors", "type": "color", "values": [
|
||
{ "id": 751, "title": "Solid White" },
|
||
{ "id": 752, "title": "Black" }
|
||
]},
|
||
{ "name": "Sizes", "type": "size", "values": [
|
||
{ "id": 2, "title": "S" },
|
||
{ "id": 3, "title": "M" }
|
||
]}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Variant level** - only stores option IDs:
|
||
```json
|
||
{ "id": 12345, "options": [751, 2], "title": "Solid White / S" }
|
||
```
|
||
|
||
### Sync Strategy: Denormalize to Labels
|
||
|
||
During sync, we **resolve IDs to labels** and store the human-readable version:
|
||
|
||
```elixir
|
||
def normalize_variant(provider_variant, product_options) do
|
||
# Build ID -> label lookup from product options
|
||
option_lookup = build_option_lookup(product_options)
|
||
# => %{751 => {"Colors", "Solid White"}, 2 => {"Sizes", "S"}}
|
||
|
||
# Convert variant's [751, 2] to %{"Colors" => "Solid White", "Sizes" => "S"}
|
||
options =
|
||
provider_variant["options"]
|
||
|> Enum.map(&Map.get(option_lookup, &1))
|
||
|> Enum.reject(&is_nil/1)
|
||
|> Map.new()
|
||
|
||
%{
|
||
provider_variant_id: to_string(provider_variant["id"]),
|
||
title: provider_variant["title"],
|
||
options: options,
|
||
# ... other fields
|
||
}
|
||
end
|
||
```
|
||
|
||
**Why denormalize?**
|
||
- No need to join to an options table on every product page load
|
||
- Labels rarely change (and if they do, re-sync updates them)
|
||
- Simpler queries, faster rendering
|
||
- Keeps our schema provider-agnostic (we just store labels, not provider-specific IDs)
|
||
|
||
### Provider Compatibility
|
||
|
||
All major POD providers can be normalized to our `options: %{"Size" => "M", "Color" => "Black"}` schema:
|
||
|
||
| Provider | Raw Variant Structure | Normalization |
|
||
|----------|----------------------|---------------|
|
||
| **Printify** | `options: [751, 2]` (IDs only, labels at product level) | Resolve IDs → labels during sync |
|
||
| **Printful** | `color: "Black", size: "M"` (flat attributes) | Map attribute keys to standard names |
|
||
| **Gelato** | `variantOptions: [{name: "Size", value: "M"}]` (array) | Convert array to map |
|
||
| **Prodigi** | `attributes: {"wrap": "Black"}` (object) | Direct mapping |
|
||
|
||
Each provider's `normalize_variant/2` function handles the transformation:
|
||
|
||
```elixir
|
||
# Printify - resolve IDs to labels
|
||
%{"options" => [751, 2]} → %{"Colors" => "Solid White", "Sizes" => "S"}
|
||
|
||
# Printful - rename keys
|
||
%{"color" => "Black", "size" => "M"} → %{"Color" => "Black", "Size" => "M"}
|
||
|
||
# Gelato - array to map
|
||
[%{"name" => "Size", "value" => "M"}] → %{"Size" => "M"}
|
||
|
||
# Prodigi - direct (already a map)
|
||
%{"wrap" => "Black"} → %{"Wrap" => "Black"}
|
||
```
|
||
|
||
Sources:
|
||
- [Printful API Documentation](https://developers.printful.com/docs/)
|
||
- [Gelato API - Get Template](https://dashboard.gelato.com/docs/ecommerce/templates/get/)
|
||
- [Prodigi API Documentation](https://www.prodigi.com/print-api/docs/reference/)
|
||
|
||
### Why JSON Options Work Well
|
||
|
||
| Use Case | Query Pattern | Efficient? |
|
||
|----------|---------------|------------|
|
||
| Product page - list variants | `where(product_id: ^id)` | Yes - no JSON query |
|
||
| Cart - get variant by ID | `Repo.get(Variant, id)` | Yes - no JSON query |
|
||
| Display options on page | Access `variant.options` | Yes - in-memory map |
|
||
| Find matching variant | `Enum.find` on loaded list | Yes - all variants loaded anyway |
|
||
|
||
A separate `variant_options` table would add joins and complexity without benefit.
|
||
|
||
### LiveView Implementation
|
||
|
||
**On mount**, load product with all variants and extract option types:
|
||
```elixir
|
||
def mount(_params, _session, socket) do
|
||
product = Products.get_product_with_variants(slug)
|
||
|
||
# Extract unique option names and values from variants
|
||
option_types = extract_option_types(product.variants)
|
||
# => %{
|
||
# "size" => ["S", "M", "L", "XL"],
|
||
# "color" => ["Black", "Green", "Blue", "Grey"],
|
||
# "sleeves" => ["Short", "Long"]
|
||
# }
|
||
|
||
{:ok, assign(socket, product: product, option_types: option_types, selected: %{})}
|
||
end
|
||
|
||
defp extract_option_types(variants) do
|
||
variants
|
||
|> Enum.flat_map(fn v -> Map.to_list(v.options) end)
|
||
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|
||
|> Map.new(fn {k, v} -> {k, Enum.uniq(v)} end)
|
||
end
|
||
```
|
||
|
||
### Cascading Selector UI
|
||
|
||
Standard e-commerce pattern with availability filtering:
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Size: [S] [M] [L] [XL] │ ← User selects "M"
|
||
├─────────────────────────────────────┤
|
||
│ Color: [Black] [Green] [Blue] │ ← Grey disabled (no M+Grey combo)
|
||
├─────────────────────────────────────┤
|
||
│ Sleeves: [Short] [Long] │ ← User selects "Long"
|
||
├─────────────────────────────────────┤
|
||
│ Price: £27.00 │
|
||
│ [Add to Cart] │ ← Adds variant "M/Black/Long"
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
**Key behaviors:**
|
||
1. User selects options one at a time
|
||
2. Unavailable combinations are disabled/greyed out
|
||
3. Once all options selected, show matching variant's price
|
||
4. Add to cart uses the specific variant ID
|
||
|
||
### Selection Handler
|
||
|
||
```elixir
|
||
def handle_event("select_option", %{"option" => option, "value" => value}, socket) do
|
||
selected = Map.put(socket.assigns.selected, option, value)
|
||
|
||
# Find matching variant (if all options selected)
|
||
current_variant = find_matching_variant(socket.assigns.product.variants, selected)
|
||
|
||
# Determine which options are still available given current selection
|
||
available_options = compute_available_options(socket.assigns.product.variants, selected)
|
||
|
||
{:noreply, assign(socket,
|
||
selected: selected,
|
||
current_variant: current_variant,
|
||
available_options: available_options
|
||
)}
|
||
end
|
||
|
||
defp find_matching_variant(variants, selected) do
|
||
Enum.find(variants, fn v -> Map.equal?(v.options, selected) end)
|
||
end
|
||
|
||
defp compute_available_options(variants, selected) do
|
||
# For each unselected option type, find values that have
|
||
# at least one variant matching current selection
|
||
# (filters out impossible combinations)
|
||
end
|
||
```
|
||
|
||
### Optional: Store Option Order on Product
|
||
|
||
For consistent selector ordering, optionally store on product:
|
||
```elixir
|
||
field :option_types, {:array, :string} # ["size", "color", "sleeves"]
|
||
```
|
||
|
||
This defines display order. Can be extracted from provider API on first sync.
|
||
|
||
---
|
||
|
||
## Provider Behaviour
|
||
|
||
```elixir
|
||
@callback provider_type() :: String.t()
|
||
@callback test_connection(conn) :: {:ok, map()} | {:error, term()}
|
||
@callback fetch_products(conn) :: {:ok, [map()]} | {:error, term()}
|
||
@callback normalize_product(raw_product) :: map() # Transform to our schema
|
||
@callback normalize_variant(raw_variant, product) :: map() # Transform + resolve options
|
||
@callback build_order_item(variant, quantity) :: map() # Build provider-specific order item
|
||
@callback submit_order(conn, order) :: {:ok, %{provider_order_id: String.t()}} | {:error, term()}
|
||
@callback get_order_status(conn, provider_order_id) :: {:ok, map()} | {:error, term()}
|
||
```
|
||
|
||
Each provider (Printify, Prodigi, Gelato) implements this behaviour with provider-specific API calls and data normalization.
|
||
|
||
---
|
||
|
||
## Order Submission Flow
|
||
|
||
When submitting an order, we must send **provider-specific variant identifiers** back to each provider.
|
||
|
||
### What Each Provider Needs
|
||
|
||
| Provider | Order Item Fields Required | Source in Our Schema |
|
||
|----------|---------------------------|---------------------|
|
||
| **Printify** | `product_id`, `variant_id`, `print_provider_id` | `product.provider_product_id`, `variant.provider_variant_id`, `product.provider_data["print_provider_id"]` |
|
||
| **Printful** | `sync_variant_id` | `variant.provider_variant_id` |
|
||
| **Gelato** | `productUid`, `itemReferenceId` | `variant.provider_variant_id`, our `order_line_item.id` |
|
||
| **Prodigi** | `sku`, `attributes` | `variant.provider_variant_id`, `variant.options` |
|
||
|
||
### Provider `build_order_item/2` Examples
|
||
|
||
```elixir
|
||
# Printify
|
||
def build_order_item(variant, quantity) do
|
||
product = variant.product
|
||
%{
|
||
"product_id" => product.provider_product_id,
|
||
"variant_id" => String.to_integer(variant.provider_variant_id),
|
||
"print_provider_id" => product.provider_data["print_provider_id"],
|
||
"quantity" => quantity
|
||
}
|
||
end
|
||
|
||
# Printful
|
||
def build_order_item(variant, quantity) do
|
||
%{
|
||
"sync_variant_id" => String.to_integer(variant.provider_variant_id),
|
||
"quantity" => quantity
|
||
}
|
||
end
|
||
|
||
# Gelato
|
||
def build_order_item(variant, quantity) do
|
||
%{
|
||
"productUid" => variant.provider_variant_id,
|
||
"quantity" => quantity,
|
||
"itemReferenceId" => Ecto.UUID.generate()
|
||
}
|
||
end
|
||
|
||
# Prodigi
|
||
def build_order_item(variant, quantity) do
|
||
%{
|
||
"sku" => variant.provider_variant_id,
|
||
"copies" => quantity,
|
||
"attributes" => variant.options # {"wrap" => "Black"} etc.
|
||
}
|
||
end
|
||
```
|
||
|
||
### Order Submission Implementation
|
||
|
||
```elixir
|
||
def submit_to_provider(%Order{} = order) do
|
||
order = Repo.preload(order, [:provider_connection, line_items: [product_variant: :product]])
|
||
provider = Provider.for_type(order.provider_connection.provider_type)
|
||
|
||
# Build provider-specific line items
|
||
items = Enum.map(order.line_items, fn item ->
|
||
provider.build_order_item(item.product_variant, item.quantity)
|
||
end)
|
||
|
||
# Submit via provider behaviour
|
||
case provider.submit_order(order.provider_connection, %{
|
||
line_items: items,
|
||
shipping_address: order.shipping_address,
|
||
external_id: order.order_number
|
||
}) do
|
||
{:ok, %{provider_order_id: provider_id}} ->
|
||
order
|
||
|> Order.changeset(%{provider_order_id: provider_id, status: "submitted"})
|
||
|> Repo.update()
|
||
|
||
{:error, reason} ->
|
||
{:error, reason}
|
||
end
|
||
end
|
||
```
|
||
|
||
### Key Insight
|
||
|
||
The `provider_variant_id` field serves a **dual purpose**:
|
||
1. **Sync**: We receive it from the provider and store it
|
||
2. **Order**: We send it back to the provider when fulfilling
|
||
|
||
This is why it's stored as a string - different providers use different formats (integers, UUIDs, compound UIDs), but we treat them all as opaque identifiers.
|
||
|
||
Sources:
|
||
- [Printify API Reference](https://developers.printify.com/)
|
||
- [Printful API Documentation](https://developers.printful.com/docs/)
|
||
- [Gelato API - Create Order](https://dashboard.gelato.com/docs/orders/v4/create/)
|
||
- [Prodigi API Documentation](https://www.prodigi.com/print-api/docs/reference/)
|
||
|
||
---
|
||
|
||
## Sync Strategy
|
||
|
||
1. Fetch all products from provider via API
|
||
2. For each product:
|
||
- Find existing by `[provider_connection_id, provider_product_id]`
|
||
- Upsert product with changeset
|
||
- Delete removed images, insert new ones
|
||
- Upsert variants (delete removed, update existing, insert new)
|
||
3. Update `provider_connection.last_synced_at`
|
||
|
||
Sync runs via Oban worker for async processing with retry on failure.
|
||
|
||
---
|
||
|
||
## Files to Create
|
||
|
||
### Dependencies
|
||
Add to `mix.exs`:
|
||
- `{:ex_money, "~> 5.0"}` - Currency handling
|
||
- `{:ex_money_sql, "~> 1.0"}` - Ecto types for Money
|
||
|
||
### Migrations
|
||
- `*_create_provider_connections.exs`
|
||
- `*_create_products.exs`
|
||
- `*_create_product_images.exs`
|
||
- `*_create_product_variants.exs`
|
||
- `*_create_orders.exs`
|
||
- `*_create_order_fulfillments.exs`
|
||
- `*_create_order_line_items.exs`
|
||
- `*_create_order_events.exs`
|
||
- `*_create_admin_notifications.exs`
|
||
|
||
### Schemas
|
||
- `lib/simpleshop_theme/products/provider_connection.ex`
|
||
- `lib/simpleshop_theme/products/product.ex`
|
||
- `lib/simpleshop_theme/products/product_image.ex`
|
||
- `lib/simpleshop_theme/products/product_variant.ex`
|
||
- `lib/simpleshop_theme/orders/order.ex`
|
||
- `lib/simpleshop_theme/orders/order_fulfillment.ex`
|
||
- `lib/simpleshop_theme/orders/order_line_item.ex`
|
||
- `lib/simpleshop_theme/orders/order_event.ex`
|
||
- `lib/simpleshop_theme/admin_notifications/notification.ex`
|
||
|
||
### Contexts
|
||
- `lib/simpleshop_theme/products.ex` - Product queries, sync logic
|
||
- `lib/simpleshop_theme/orders.ex` - Order creation, submission
|
||
- `lib/simpleshop_theme/admin_notifications.ex` - Admin notification management
|
||
|
||
### Providers
|
||
- `lib/simpleshop_theme/providers/provider.ex` - Behaviour definition
|
||
- `lib/simpleshop_theme/providers/printify.ex` - Printify implementation
|
||
|
||
### Workers
|
||
- `lib/simpleshop_theme/sync/product_sync_worker.ex` - Oban worker
|
||
|
||
### Webhooks
|
||
- `lib/simpleshop_theme_web/controllers/webhook_controller.ex`
|
||
- `lib/simpleshop_theme/webhooks/printify_handler.ex`
|
||
|
||
### Notifiers
|
||
- `lib/simpleshop_theme_web/notifiers/customer_notifier.ex` - Customer emails
|
||
|
||
### Support
|
||
- `lib/simpleshop_theme/vault.ex` - Credential encryption
|
||
|
||
---
|
||
|
||
## Files to Modify
|
||
|
||
- `lib/simpleshop_theme/printify/client.ex` → Move to `lib/simpleshop_theme/clients/printify.ex`
|
||
- `lib/simpleshop_theme/printify/mockup_generator.ex` → Move to `lib/simpleshop_theme/mockups/generator.ex`
|
||
- `lib/simpleshop_theme/theme/preview_data.ex` - Query real products when available
|
||
- `lib/simpleshop_theme_web/live/shop_live/*.ex` - Use Products context instead of PreviewData
|
||
|
||
---
|
||
|
||
## Implementation Phases
|
||
|
||
### Phase 1: Schema Foundation
|
||
1. Create Vault module for credential encryption
|
||
2. Create all migrations
|
||
3. Create all schema modules
|
||
4. Run migrations
|
||
|
||
### Phase 2: Products Context
|
||
1. Create Products context with CRUD
|
||
2. Implement sync/upsert logic
|
||
3. Create ProductSyncWorker (Oban)
|
||
4. Update Printify.Client with get_products endpoint
|
||
|
||
### Phase 3: Provider Abstraction
|
||
1. Create Provider behaviour
|
||
2. Implement Printify provider
|
||
3. Connect admin UI for provider setup
|
||
4. Test sync flow end-to-end
|
||
|
||
### Phase 4: Orders
|
||
1. Create Orders context
|
||
2. Implement order submission to provider
|
||
3. Add order status tracking
|
||
|
||
### Phase 5: Integration
|
||
1. Update PreviewData to use real products
|
||
2. Update shop LiveViews
|
||
3. Update cart to use real variant IDs
|
||
|
||
---
|
||
|
||
## Notifications Strategy
|
||
|
||
### Admin Notifications (Internal)
|
||
Stored in database, shown in admin dashboard:
|
||
|
||
```elixir
|
||
schema "admin_notifications" do
|
||
field :type, :string # "sync_error", "order_failed", "webhook_error"
|
||
field :severity, :string # "info", "warning", "error"
|
||
field :title, :string
|
||
field :message, :string
|
||
field :metadata, :map
|
||
field :read_at, :utc_datetime
|
||
timestamps()
|
||
end
|
||
```
|
||
|
||
**Use cases:** Sync failures, order submission errors, provider API issues.
|
||
|
||
### Customer Notifications
|
||
Two mechanisms:
|
||
1. **Transactional emails** via `CustomerNotifier` (order confirmation, shipping updates)
|
||
2. **Order events** table for timeline display on order page
|
||
|
||
```elixir
|
||
# order_events - visible to customer on their order page
|
||
schema "order_events" do
|
||
belongs_to :order
|
||
field :type, :string # "placed", "paid", "shipped", "delivered"
|
||
field :message, :string # Human-readable status
|
||
field :metadata, :map # {tracking_number, carrier, fulfillment_id}
|
||
timestamps()
|
||
end
|
||
```
|
||
|
||
### Files to Create
|
||
- `lib/simpleshop_theme/admin_notifications.ex` - Admin notification context
|
||
- `lib/simpleshop_theme/admin_notifications/notification.ex` - Schema
|
||
- `lib/simpleshop_theme/orders/order_event.ex` - Customer-facing event schema
|
||
- `lib/simpleshop_theme_web/notifiers/customer_notifier.ex` - Emails
|
||
|
||
---
|
||
|
||
## Data Integrity: Checksum Approach
|
||
|
||
Use checksums to detect changes during sync (more robust than `updated_at` across providers):
|
||
|
||
```elixir
|
||
# On product schema
|
||
field :provider_checksum, :string
|
||
|
||
# Computing checksum
|
||
def compute_checksum(provider_data) do
|
||
provider_data
|
||
|> normalize_for_checksum()
|
||
|> Jason.encode!(pretty: false)
|
||
|> then(&:crypto.hash(:sha256, &1))
|
||
|> Base.encode16(case: :lower)
|
||
end
|
||
|
||
defp normalize_for_checksum(data) do
|
||
data
|
||
|> Map.drop(["created_at", "updated_at"]) # Ignore volatile fields
|
||
|> sort_keys_recursively()
|
||
end
|
||
|
||
# During sync
|
||
def needs_update?(existing_product, provider_data) do
|
||
new_checksum = compute_checksum(provider_data)
|
||
existing_product.provider_checksum != new_checksum
|
||
end
|
||
```
|
||
|
||
**Benefits:**
|
||
- Works regardless of provider timestamp support
|
||
- Catches any field change
|
||
- Avoids unnecessary database writes
|
||
- Stable across JSON key ordering variations
|
||
|
||
---
|
||
|
||
## Edge Cases (Follow-up Implementation Notes)
|
||
|
||
| Edge Case | Expected Behavior |
|
||
|-----------|-------------------|
|
||
| **Provider API down during sync** | Oban retries with exponential backoff (30s → 5min). After max_attempts (3), mark sync as `failed`. Create admin notification. |
|
||
| **Product deleted on provider** | Compare local products with provider list during sync. Mark missing products as `status: "archived"` (don't hard delete - preserves order history). |
|
||
| **Variant prices change mid-cart** | Cart stores `cached_price` at add-time. On checkout, re-validate current prices. Show warning to customer if price increased, allow proceed if decreased. |
|
||
| **`provider_variant_id` format changes** | Already stored as string. Provider behaviour's `normalize_variant/2` handles format. If API version changes, update provider module. |
|
||
| **Provider returns malformed data** | Validate in `normalize_product/1`. Log error, skip malformed product, continue batch. Don't fail entire sync. |
|
||
| **Rate limiting from provider** | Single sync worker (queue concurrency: 1). Add configurable delay between paginated requests. Respect Retry-After headers. |
|
||
| **Sync interrupted mid-batch** | Track sync progress in `provider_connection.sync_metadata`. On resume, continue from last successful page. |
|
||
| **Duplicate slugs across providers** | Generate slug with provider suffix on collision: `awesome-shirt-abc123` where `abc123` is short provider product ID. |
|
||
| **Webhook signature validation** | Each provider has different signing. Validate HMAC signature before processing. Reject invalid webhooks with 401. |
|
||
| **Order submission fails** | Keep order as `status: "pending"`. Retry via Oban worker. After max retries, mark as `status: "failed"`, notify admin. |
|
||
|
||
---
|
||
|
||
## Testing Strategy
|
||
|
||
### Test Setup
|
||
|
||
**Mocking External APIs (Mox pattern):**
|
||
```elixir
|
||
# test/support/mocks.ex
|
||
Mox.defmock(SimpleshopTheme.Clients.MockPrintify, for: SimpleshopTheme.Clients.PrintifyBehaviour)
|
||
|
||
# config/test.exs
|
||
config :simpleshop_theme, :printify_client, SimpleshopTheme.Clients.MockPrintify
|
||
```
|
||
|
||
**Oban testing:**
|
||
```elixir
|
||
use Oban.Testing, repo: SimpleshopTheme.Repo
|
||
# Jobs run synchronously in tests via perform_job/2
|
||
```
|
||
|
||
### Unit Tests
|
||
|
||
| Module | Test Coverage |
|
||
|--------|---------------|
|
||
| **Products.Product** | Changeset validation, slug generation, status transitions |
|
||
| **Products.ProductVariant** | Options validation, price/cost integrity |
|
||
| **Products.ProviderConnection** | Credential encryption/decryption, config validation |
|
||
| **Providers.Printify** | `normalize_product/1`, `normalize_variant/2`, `build_order_item/2` |
|
||
| **Orders.Order** | Status aggregation from fulfillments, total calculation |
|
||
| **Orders.OrderFulfillment** | Status transitions, tracking updates |
|
||
| **AdminNotifications** | Create, list_unread, mark_read |
|
||
|
||
### Integration Tests
|
||
|
||
| Flow | Test Scenarios |
|
||
|------|----------------|
|
||
| **Product Sync** | Successful sync, partial failures, pagination, checksum skip, archived products |
|
||
| **Order Creation** | Single provider, multi-provider split into fulfillments |
|
||
| **Order Submission** | Success, API failure with retry, partial fulfillment submission |
|
||
| **Webhook Handling** | Valid signature, invalid signature rejection, status updates |
|
||
| **Variant Selector** | Option extraction, availability filtering, variant matching |
|
||
|
||
### Example Test Cases
|
||
|
||
```elixir
|
||
# test/simpleshop_theme/products_test.exs
|
||
describe "sync_products/1" do
|
||
test "syncs products from provider" do
|
||
conn = provider_connection_fixture()
|
||
|
||
expect(MockPrintify, :list_products, fn _shop_id ->
|
||
{:ok, [printify_product_fixture()]}
|
||
end)
|
||
|
||
assert {:ok, %{synced: 1, failed: 0}} = Products.sync_products(conn)
|
||
assert [product] = Products.list_products()
|
||
assert product.title == "Test Product"
|
||
end
|
||
|
||
test "continues on single product failure" do
|
||
expect(MockPrintify, :list_products, fn _ ->
|
||
{:ok, [valid_product(), malformed_product()]}
|
||
end)
|
||
|
||
assert {:ok, %{synced: 1, failed: 1}} = Products.sync_products(conn)
|
||
end
|
||
|
||
test "skips unchanged products via checksum" do
|
||
existing = product_fixture(provider_checksum: "abc123")
|
||
|
||
expect(MockPrintify, :list_products, fn _ ->
|
||
{:ok, [product_with_same_checksum()]}
|
||
end)
|
||
|
||
assert {:ok, %{synced: 0, skipped: 1}} = Products.sync_products(conn)
|
||
end
|
||
end
|
||
|
||
# test/simpleshop_theme/orders_test.exs
|
||
describe "create_order_from_cart/1" do
|
||
test "splits cart into fulfillments by provider" do
|
||
printify_variant = variant_fixture(provider: :printify)
|
||
gelato_variant = variant_fixture(provider: :gelato)
|
||
|
||
cart = [
|
||
%{variant_id: printify_variant.id, quantity: 1},
|
||
%{variant_id: gelato_variant.id, quantity: 2}
|
||
]
|
||
|
||
assert {:ok, order} = Orders.create_order_from_cart(cart, customer_params())
|
||
assert length(order.order_fulfillments) == 2
|
||
end
|
||
end
|
||
|
||
# test/simpleshop_theme/sync/product_sync_worker_test.exs
|
||
describe "perform/1" do
|
||
test "retries on API failure" do
|
||
expect(MockPrintify, :list_products, fn _ -> {:error, :timeout} end)
|
||
|
||
assert {:error, :timeout} = perform_job(ProductSyncWorker, %{conn_id: conn.id})
|
||
# Oban will retry automatically
|
||
end
|
||
|
||
test "creates admin notification on final failure" do
|
||
# After max_attempts...
|
||
assert [notification] = AdminNotifications.list_unread()
|
||
assert notification.type == "sync_error"
|
||
end
|
||
end
|
||
```
|
||
|
||
### Test Fixtures
|
||
|
||
```elixir
|
||
# test/support/fixtures/products_fixtures.ex
|
||
def provider_connection_fixture(attrs \\ %{}) do
|
||
{:ok, conn} = Products.create_provider_connection(%{
|
||
provider_type: "printify",
|
||
name: "Test Connection",
|
||
api_key: "test_key",
|
||
config: %{"shop_id" => "123"}
|
||
} |> Map.merge(attrs))
|
||
conn
|
||
end
|
||
|
||
def product_fixture(attrs \\ %{}) do
|
||
conn = attrs[:provider_connection] || provider_connection_fixture()
|
||
{:ok, product} = Products.create_product(%{
|
||
provider_connection_id: conn.id,
|
||
provider_product_id: "ext_#{System.unique_integer()}",
|
||
title: "Test Product",
|
||
slug: "test-product-#{System.unique_integer()}",
|
||
status: "active"
|
||
} |> Map.merge(attrs))
|
||
product
|
||
end
|
||
|
||
def printify_product_fixture do
|
||
%{
|
||
"id" => "12345",
|
||
"title" => "Test Product",
|
||
"description" => "A test product",
|
||
"options" => [%{"name" => "Size", "values" => [%{"id" => 1, "title" => "M"}]}],
|
||
"variants" => [%{"id" => 100, "options" => [1], "price" => 2500}],
|
||
"images" => [%{"src" => "https://example.com/img.jpg", "position" => 0}]
|
||
}
|
||
end
|
||
```
|
||
|
||
### Coverage Goals
|
||
|
||
| Area | Target |
|
||
|------|--------|
|
||
| Schema modules | 100% (validation, associations) |
|
||
| Context functions | 90%+ (all public functions) |
|
||
| Provider implementations | 90%+ (normalize, build functions) |
|
||
| Oban workers | 80%+ (happy path, error handling) |
|
||
| LiveView interactions | 70%+ (critical user flows) |
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
2. **Product Sync**
|
||
- Run `ProductSyncWorker.enqueue(conn.id)`
|
||
- Products appear in database with images and variants
|
||
- Re-sync updates existing, doesn't duplicate
|
||
|
||
3. **Order Submission**
|
||
- Create order from cart
|
||
- `Orders.submit_to_provider(order)` returns provider_order_id
|
||
- Order status updates from provider
|
||
|
||
4. **Shop Pages**
|
||
- `/collections/all` shows real products
|
||
- `/products/:slug` shows product details
|
||
- Add to cart works with real variant IDs
|