feat: add admin provider setup UI with improved product sync
- Add /admin/providers LiveView for connecting and managing POD providers - Implement pagination for Printify API (handles all products, not just first page) - Add parallel processing (5 concurrent) for faster product sync - Add slug-based fallback matching when provider_product_id changes - Add error recovery with try/rescue to prevent stuck sync status - Add checksum-based change detection to skip unchanged products - Add upsert tests covering race conditions and slug matching - Add Printify provider tests - Document Printify integration research (product identity, order risks, open source vs managed hosting implications) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bbd748f123
commit
5b736b99fd
26
PROGRESS.md
26
PROGRESS.md
@ -12,16 +12,14 @@
|
|||||||
- 100% PageSpeed score
|
- 100% PageSpeed score
|
||||||
|
|
||||||
**In Progress:**
|
**In Progress:**
|
||||||
- Products context with provider integration (Phase 1 complete)
|
- Products context with provider integration (sync working, wiring to shop views next)
|
||||||
|
|
||||||
## Next Up
|
## Next Up
|
||||||
|
|
||||||
1. **Admin Provider Setup UI** (`/admin/providers`) - Add/edit/test provider connections
|
1. **Printify Webhook Endpoint** - Real-time product updates when changes happen in Printify
|
||||||
2. **Product Sync Strategy:**
|
2. **Wire Products to Shop LiveViews** - Replace PreviewData with real synced products
|
||||||
- ProductSyncWorker (Oban) for manual/scheduled sync
|
3. **Variant Selector Component** - Size/colour picker on product pages
|
||||||
- Webhook endpoint for real-time updates from Printify
|
4. **Session-based Cart** - Real cart with actual variants
|
||||||
3. Wire Products context to shop LiveViews (replace PreviewData)
|
|
||||||
4. Session-based cart with real variants
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -57,15 +55,25 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im
|
|||||||
- [x] Provider abstraction layer
|
- [x] Provider abstraction layer
|
||||||
- [x] Printify client integration
|
- [x] Printify client integration
|
||||||
- [x] Product/variant/image schemas
|
- [x] Product/variant/image schemas
|
||||||
|
- [x] Admin Provider Setup UI (`/admin/providers`) - connect, test, sync
|
||||||
|
- [x] ProductSyncWorker with pagination, parallel processing, error recovery
|
||||||
|
- [x] Slug-based fallback matching for changed provider IDs
|
||||||
|
|
||||||
#### Remaining Tasks
|
#### Remaining Tasks
|
||||||
- [ ] **Admin Provider Setup UI** - `/admin/providers` for managing connections (~2hr) ← **NEXT**
|
|
||||||
- [ ] Create ProductSyncWorker (Oban) for manual/scheduled sync (~1hr)
|
|
||||||
- [ ] Printify webhook endpoint for real-time product updates (~1.5hr)
|
- [ ] 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)
|
||||||
|
|
||||||
|
#### Future Enhancements (post-MVP)
|
||||||
|
- [ ] Pre-checkout variant validation (verify availability before order)
|
||||||
|
- [ ] Cost change monitoring/alerts (warn if Printify cost increased)
|
||||||
|
- [ ] OAuth platform integration (appear in Printify's "Publish to" UI)
|
||||||
|
|
||||||
|
#### Technical Debt
|
||||||
|
- [ ] Add HTTP mocking (Mox/Bypass) for Printify API tests
|
||||||
|
|
||||||
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
|
||||||
|
See: [docs/plans/printify-integration-research.md](docs/plans/printify-integration-research.md) for API research & risk analysis
|
||||||
|
|
||||||
### Cart & Checkout
|
### Cart & Checkout
|
||||||
**Status:** Planned
|
**Status:** Planned
|
||||||
|
|||||||
323
docs/plans/printify-integration-research.md
Normal file
323
docs/plans/printify-integration-research.md
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
# Printify Integration Research
|
||||||
|
|
||||||
|
> Research notes from investigating Printify API integration, product syncing, and order submission risks. This document captures findings for future implementation work.
|
||||||
|
|
||||||
|
## Product Identity & Matching
|
||||||
|
|
||||||
|
### What identifiers exist?
|
||||||
|
|
||||||
|
| Field | Stable? | Notes |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| `id` (provider_product_id) | ❌ | Changes if product deleted & recreated |
|
||||||
|
| `sku` (variant level) | ❌ | Auto-generated per product creation |
|
||||||
|
| `blueprint_id` | ✅ | The base product type (e.g., Gildan 5000) |
|
||||||
|
| `print_provider_id` | ✅ | The fulfillment provider |
|
||||||
|
| `title` | ✅ | User-defined, stable unless edited |
|
||||||
|
|
||||||
|
### Our matching strategy
|
||||||
|
|
||||||
|
1. **Primary**: Match by `provider_product_id`
|
||||||
|
2. **Fallback**: Match by `slug` (derived from title)
|
||||||
|
3. **Potential improvement**: Match by `blueprint_id + print_provider_id + title`
|
||||||
|
|
||||||
|
The slug fallback handles cases where Printify product IDs change (e.g., product deleted and recreated with same title).
|
||||||
|
|
||||||
|
### How Shopify/Printify official integration works
|
||||||
|
|
||||||
|
- SKU is generated once when first published to Shopify
|
||||||
|
- SKU becomes the stable link for future updates
|
||||||
|
- Republishing matches by SKU
|
||||||
|
- The "publish lock" prevents editing after publishing
|
||||||
|
|
||||||
|
## Duplicate Products
|
||||||
|
|
||||||
|
### When duplicates occur
|
||||||
|
|
||||||
|
- Mock/test product generators run multiple times
|
||||||
|
- User accidentally creates products twice in Printify
|
||||||
|
- Migration scenarios with orphaned products
|
||||||
|
- Using multiple POD providers on same store
|
||||||
|
|
||||||
|
### How we detect duplicates
|
||||||
|
|
||||||
|
Same `title` (and therefore same `slug`) but different `provider_product_id`.
|
||||||
|
|
||||||
|
In our test case, Printify returned 30 products but only 16 unique titles - every product existed twice with different IDs but identical:
|
||||||
|
- `title`
|
||||||
|
- `blueprint_id`
|
||||||
|
- `print_provider_id`
|
||||||
|
|
||||||
|
### How we handle duplicates
|
||||||
|
|
||||||
|
Our `upsert_product/2` function matches by slug when provider_product_id lookup fails, treating them as the same product and updating the provider_product_id to the latest value.
|
||||||
|
|
||||||
|
### Is this a common real-world issue?
|
||||||
|
|
||||||
|
Research suggests **no** - duplicate products aren't commonly reported. Main sync issues are:
|
||||||
|
- Orders not syncing
|
||||||
|
- Store connection expiring
|
||||||
|
- Publishing failures
|
||||||
|
- Product info being overwritten on republish
|
||||||
|
|
||||||
|
The official Shopify integration handles product identity well through SKUs.
|
||||||
|
|
||||||
|
## Sales Channel Integration Options
|
||||||
|
|
||||||
|
### Option 1: Personal API Token (current)
|
||||||
|
|
||||||
|
- Pull products via API
|
||||||
|
- Works immediately, no approval needed
|
||||||
|
- We don't appear in Printify's "Publish to..." UI
|
||||||
|
- Products not "locked" after sync
|
||||||
|
|
||||||
|
### Option 2: OAuth Platform Integration
|
||||||
|
|
||||||
|
- Apply at printify.com/printify-api
|
||||||
|
- ~1 week approval process
|
||||||
|
- Merchants authorize SimpleShop via OAuth
|
||||||
|
- We appear in Printify's publishing UI alongside Shopify/Etsy
|
||||||
|
- Products get "published" TO us with lock
|
||||||
|
- Webhooks for real-time updates
|
||||||
|
|
||||||
|
**How publishing works in Option 2:**
|
||||||
|
1. Merchant clicks "Publish to SimpleShop" in Printify
|
||||||
|
2. Printify sends `product:publish:started` webhook
|
||||||
|
3. We create the product on our side
|
||||||
|
4. We call `publishing_succeeded.json` to confirm
|
||||||
|
5. Product locked in Printify, shows as "Published to SimpleShop"
|
||||||
|
|
||||||
|
**Benefits of official integration:**
|
||||||
|
- Publish lock prevents editing after publishing (data consistency)
|
||||||
|
- Real-time webhooks
|
||||||
|
- "Official" feel for merchants
|
||||||
|
- Better UX in Printify dashboard
|
||||||
|
|
||||||
|
**Downsides:**
|
||||||
|
- Approval process
|
||||||
|
- More complex OAuth auth
|
||||||
|
- Ongoing partnership relationship
|
||||||
|
|
||||||
|
## Order Submission Risks
|
||||||
|
|
||||||
|
### How order submission works
|
||||||
|
|
||||||
|
We send to Printify:
|
||||||
|
```elixir
|
||||||
|
%{
|
||||||
|
product_id: "printify_product_id",
|
||||||
|
variant_id: 12345,
|
||||||
|
quantity: 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We **do not send price**. Printify charges their current wholesale cost.
|
||||||
|
|
||||||
|
### Risk scenarios
|
||||||
|
|
||||||
|
| Scenario | Result | Who bears cost? |
|
||||||
|
|----------|--------|-----------------|
|
||||||
|
| Wholesale cost increased | Order succeeds | Shop owner (you) |
|
||||||
|
| Wholesale cost decreased | Order succeeds | Shop owner profits more |
|
||||||
|
| Variant discontinued | Order **fails** | Customer experience |
|
||||||
|
| Product deleted | Order **fails** | Customer experience |
|
||||||
|
| Design changed | Order succeeds with new design | Customer gets wrong item |
|
||||||
|
|
||||||
|
### Why the publish lock matters
|
||||||
|
|
||||||
|
In official integrations, the publish lock prevents merchants from editing products after publishing. This ensures:
|
||||||
|
- Price consistency between storefront and fulfillment cost
|
||||||
|
- Variant availability guaranteed
|
||||||
|
- Design matches what customer saw
|
||||||
|
|
||||||
|
Without the lock (our current approach), products can change between sync and order.
|
||||||
|
|
||||||
|
### Recommended mitigations
|
||||||
|
|
||||||
|
1. **Webhooks / frequent polling**
|
||||||
|
- Subscribe to `product:updated`, `product:deleted` events
|
||||||
|
- Or poll every N minutes for changes
|
||||||
|
- Update local product data immediately
|
||||||
|
|
||||||
|
2. **Pre-checkout validation**
|
||||||
|
- Before completing checkout, call Printify API
|
||||||
|
- Verify variant still exists
|
||||||
|
- Get current cost, compare to stored cost
|
||||||
|
- Alert or block if significant difference
|
||||||
|
|
||||||
|
3. **Cost monitoring**
|
||||||
|
- Store `cost` from Printify on each variant
|
||||||
|
- During sync, compare old vs new cost
|
||||||
|
- Alert shop owner if cost increased significantly
|
||||||
|
- Consider auto-adjusting retail price
|
||||||
|
|
||||||
|
4. **Graceful order failure handling**
|
||||||
|
- Catch "variant not found" errors
|
||||||
|
- Notify customer immediately
|
||||||
|
- Offer refund or alternative
|
||||||
|
- Don't leave order in limbo
|
||||||
|
|
||||||
|
5. **Stale data warnings**
|
||||||
|
- Track `last_synced_at` per product
|
||||||
|
- Warn in admin if product not synced recently
|
||||||
|
- Consider blocking orders for very stale products
|
||||||
|
|
||||||
|
## API Rate Limits
|
||||||
|
|
||||||
|
From Printify documentation:
|
||||||
|
- 600 requests/minute global
|
||||||
|
- 100 requests/minute for catalog endpoints
|
||||||
|
- 50 products per page max
|
||||||
|
- Product publishing: 200 requests per 30 minutes
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- [x] Product sync with pagination
|
||||||
|
- [x] Parallel processing (5 concurrent)
|
||||||
|
- [x] Slug-based fallback matching
|
||||||
|
- [x] Error recovery (try/rescue)
|
||||||
|
- [x] Checksum-based change detection
|
||||||
|
|
||||||
|
### Not yet implemented
|
||||||
|
- [ ] Webhook endpoint for real-time updates
|
||||||
|
- [ ] Pre-checkout variant validation
|
||||||
|
- [ ] Cost change monitoring/alerts
|
||||||
|
- [ ] OAuth platform integration (requires Printify approval)
|
||||||
|
- [ ] `blueprint_id + print_provider_id` matching
|
||||||
|
|
||||||
|
## Open Source vs Managed Hosting Considerations
|
||||||
|
|
||||||
|
### The core tension
|
||||||
|
|
||||||
|
SimpleShop exists in two forms:
|
||||||
|
1. **Open source** - self-hosted by anyone
|
||||||
|
2. **Managed hosting** - SaaS service run by us
|
||||||
|
|
||||||
|
Printify's integration options have different implications for each.
|
||||||
|
|
||||||
|
### Personal API Token (current approach)
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- Each merchant creates their own Printify API token
|
||||||
|
- Token entered in SimpleShop admin
|
||||||
|
- Direct API access, no intermediary
|
||||||
|
|
||||||
|
**Open source:** ✅ Works perfectly
|
||||||
|
- Each self-hosted instance uses merchant's own token
|
||||||
|
- No central service required
|
||||||
|
- Full functionality
|
||||||
|
|
||||||
|
**Managed hosting:** ✅ Works perfectly
|
||||||
|
- Same as self-hosted
|
||||||
|
- Each tenant uses their own token
|
||||||
|
- No special relationship with Printify needed
|
||||||
|
|
||||||
|
**Downsides:**
|
||||||
|
- Merchants must manually create/manage API tokens
|
||||||
|
- No "Connect with Printify" button UX
|
||||||
|
- Products not locked after sync (data consistency risk)
|
||||||
|
- No real-time webhooks (must poll or manual sync)
|
||||||
|
|
||||||
|
### OAuth Platform Integration
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- Register SimpleShop as an app with Printify
|
||||||
|
- Get OAuth client ID/secret
|
||||||
|
- Merchants click "Connect" and authorize
|
||||||
|
- We receive access tokens, appear in Printify UI
|
||||||
|
|
||||||
|
**Open source:** ❌ Problematic
|
||||||
|
- OAuth requires registered callback URLs
|
||||||
|
- Can't register infinite self-hosted domains
|
||||||
|
- Would need to proxy through a central service
|
||||||
|
- Defeats the "fully self-hosted" value proposition
|
||||||
|
|
||||||
|
**Managed hosting:** ✅ Works well
|
||||||
|
- Single registered callback URL
|
||||||
|
- "Connect with Printify" button
|
||||||
|
- Appears as official integration
|
||||||
|
- Real-time webhooks
|
||||||
|
- Publish lock for data consistency
|
||||||
|
|
||||||
|
### Hybrid approach possibilities
|
||||||
|
|
||||||
|
**Option A: Token for open source, OAuth for managed**
|
||||||
|
- Open source uses personal API tokens (current)
|
||||||
|
- Managed hosting uses OAuth integration
|
||||||
|
- Two code paths, more maintenance
|
||||||
|
- Clear value differentiation
|
||||||
|
|
||||||
|
**Option B: Webhook proxy service**
|
||||||
|
- Register OAuth app
|
||||||
|
- Self-hosted instances connect through managed webhook proxy
|
||||||
|
- Proxy forwards webhooks to self-hosted URLs
|
||||||
|
- Adds dependency on central service
|
||||||
|
- Could be free tier for open source users
|
||||||
|
|
||||||
|
**Option C: OAuth with self-registration**
|
||||||
|
- Document how to register own OAuth app with Printify
|
||||||
|
- Self-hosters go through Printify approval themselves
|
||||||
|
- Complex, unlikely many would do this
|
||||||
|
- Each instance is independent
|
||||||
|
|
||||||
|
### Business model implications
|
||||||
|
|
||||||
|
| Approach | Open Source | Managed Hosting |
|
||||||
|
|----------|-------------|-----------------|
|
||||||
|
| Personal API Token | Full feature parity | No differentiation |
|
||||||
|
| OAuth (managed only) | Basic sync only | Premium "official" integration |
|
||||||
|
| Webhook proxy | Depends on proxy | Full features |
|
||||||
|
|
||||||
|
**Potential differentiation for managed hosting:**
|
||||||
|
- Official "Publish to SimpleShop" in Printify UI
|
||||||
|
- Real-time sync via webhooks
|
||||||
|
- Publish lock (data consistency guarantee)
|
||||||
|
- Pre-checkout validation (verify before order)
|
||||||
|
- No token management for merchants
|
||||||
|
|
||||||
|
**What open source keeps:**
|
||||||
|
- Full product sync (polling-based)
|
||||||
|
- Order submission
|
||||||
|
- All admin features
|
||||||
|
- Self-hosted independence
|
||||||
|
|
||||||
|
### Printify partnership considerations
|
||||||
|
|
||||||
|
**What registering as an app might require:**
|
||||||
|
- Application/approval process (~1 week)
|
||||||
|
- Technical integration review
|
||||||
|
- Ongoing partnership relationship
|
||||||
|
- Support obligations?
|
||||||
|
- Usage/volume commitments?
|
||||||
|
|
||||||
|
**Questions to research:**
|
||||||
|
- [ ] What are Printify's requirements for app partners?
|
||||||
|
- [ ] Are there fees or revenue sharing?
|
||||||
|
- [ ] Can we register once for managed hosting only?
|
||||||
|
- [ ] What support/SLA obligations exist?
|
||||||
|
- [ ] Can app be limited to specific redirect URIs (managed only)?
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**Phase 1 (now):** Personal API tokens for both versions
|
||||||
|
- Works everywhere
|
||||||
|
- No partnership dependencies
|
||||||
|
- Validates product-market fit first
|
||||||
|
|
||||||
|
**Phase 2 (if managed hosting gains traction):** OAuth for managed only
|
||||||
|
- Apply for Printify app registration
|
||||||
|
- Implement OAuth flow for managed platform
|
||||||
|
- Keep token-based flow for open source
|
||||||
|
- Use as value differentiator
|
||||||
|
|
||||||
|
**Phase 3 (optional):** Webhook proxy for open source
|
||||||
|
- If demand exists for real-time sync in self-hosted
|
||||||
|
- Could be free or paid add-on
|
||||||
|
- Maintains open source independence while adding capability
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Printify API Reference](https://developers.printify.com/)
|
||||||
|
- [Printify Help - Sales Channels](https://help.printify.com/hc/en-us/articles/4483630572945-Which-sales-channels-does-Printify-integrate-with)
|
||||||
|
- [Printify Partner Application](https://printify.com/become-a-partner/)
|
||||||
|
- [Shopify Product Status](https://community.shopify.com/t/what-is-the-difference-of-unpublished-product-draft-product/30964)
|
||||||
@ -154,9 +154,10 @@ defmodule SimpleshopTheme.Clients.Printify do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
List all products in a shop.
|
List all products in a shop.
|
||||||
|
Printify allows a maximum of 50 products per page.
|
||||||
"""
|
"""
|
||||||
def list_products(shop_id, opts \\ []) do
|
def list_products(shop_id, opts \\ []) do
|
||||||
limit = Keyword.get(opts, :limit, 100)
|
limit = Keyword.get(opts, :limit, 50)
|
||||||
page = Keyword.get(opts, :page, 1)
|
page = Keyword.get(opts, :page, 1)
|
||||||
get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
|
get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
|
||||||
end
|
end
|
||||||
|
|||||||
@ -28,6 +28,13 @@ defmodule SimpleshopTheme.Products do
|
|||||||
Repo.get(ProviderConnection, id)
|
Repo.get(ProviderConnection, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single provider connection, raising if not found.
|
||||||
|
"""
|
||||||
|
def get_provider_connection!(id) do
|
||||||
|
Repo.get!(ProviderConnection, id)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a provider connection by type.
|
Gets a provider connection by type.
|
||||||
"""
|
"""
|
||||||
@ -72,6 +79,24 @@ defmodule SimpleshopTheme.Products do
|
|||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the count of products for a provider connection.
|
||||||
|
"""
|
||||||
|
def count_products_for_connection(nil), do: 0
|
||||||
|
|
||||||
|
def count_products_for_connection(connection_id) do
|
||||||
|
from(p in Product, where: p.provider_connection_id == ^connection_id, select: count())
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Enqueues a product sync job for the given provider connection.
|
||||||
|
Returns `{:ok, job}` or `{:error, changeset}`.
|
||||||
|
"""
|
||||||
|
def enqueue_sync(%ProviderConnection{} = conn) do
|
||||||
|
SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id)
|
||||||
|
end
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Products
|
# Products
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -160,16 +185,19 @@ defmodule SimpleshopTheme.Products do
|
|||||||
def upsert_product(%ProviderConnection{id: conn_id}, attrs) do
|
def upsert_product(%ProviderConnection{id: conn_id}, attrs) do
|
||||||
provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"]
|
provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"]
|
||||||
new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"])
|
new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"])
|
||||||
attrs = Map.put(attrs, :checksum, new_checksum)
|
title = attrs[:title] || attrs["title"]
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
attrs
|
||||||
|
|> Map.put(:checksum, new_checksum)
|
||||||
|
|> Map.put(:provider_connection_id, conn_id)
|
||||||
|
|
||||||
|
# First check by provider_product_id
|
||||||
case get_product_by_provider(conn_id, provider_product_id) do
|
case get_product_by_provider(conn_id, provider_product_id) do
|
||||||
nil ->
|
nil ->
|
||||||
attrs = Map.put(attrs, :provider_connection_id, conn_id)
|
# Not found by provider ID - check by slug (same title = same product)
|
||||||
|
slug = Slug.slugify(title)
|
||||||
case create_product(attrs) do
|
find_by_slug_or_insert(conn_id, slug, attrs, new_checksum)
|
||||||
{:ok, product} -> {:ok, product, :created}
|
|
||||||
error -> error
|
|
||||||
end
|
|
||||||
|
|
||||||
%Product{checksum: ^new_checksum} = product ->
|
%Product{checksum: ^new_checksum} = product ->
|
||||||
{:ok, product, :unchanged}
|
{:ok, product, :unchanged}
|
||||||
@ -182,6 +210,77 @@ defmodule SimpleshopTheme.Products do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# If product exists with same slug, update it (including new provider_product_id)
|
||||||
|
# Otherwise insert new product
|
||||||
|
defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
|
||||||
|
case get_product_by_slug(slug) do
|
||||||
|
%Product{provider_connection_id: ^conn_id, checksum: ^new_checksum} = product ->
|
||||||
|
# Same product, same checksum - just update the provider_product_id if changed
|
||||||
|
if product.provider_product_id != attrs[:provider_product_id] do
|
||||||
|
case update_product(product, %{provider_product_id: attrs[:provider_product_id]}) do
|
||||||
|
{:ok, product} -> {:ok, product, :updated}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:ok, product, :unchanged}
|
||||||
|
end
|
||||||
|
|
||||||
|
%Product{provider_connection_id: ^conn_id} = product ->
|
||||||
|
# Same product, different checksum - full update including new provider_product_id
|
||||||
|
case update_product(product, attrs) do
|
||||||
|
{:ok, product} -> {:ok, product, :updated}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# Not found or belongs to different connection - insert new
|
||||||
|
do_insert_product(attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Insert with conflict handling for race conditions
|
||||||
|
defp do_insert_product(attrs) do
|
||||||
|
case create_product(attrs) do
|
||||||
|
{:ok, product} ->
|
||||||
|
{:ok, product, :created}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
|
||||||
|
# Check if it's a unique constraint violation (race condition)
|
||||||
|
if has_unique_constraint_error?(errors) do
|
||||||
|
handle_insert_conflict(attrs, changeset)
|
||||||
|
else
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_insert_conflict(attrs, changeset) do
|
||||||
|
conn_id = attrs[:provider_connection_id]
|
||||||
|
provider_product_id = attrs[:provider_product_id]
|
||||||
|
new_checksum = attrs[:checksum]
|
||||||
|
|
||||||
|
case get_product_by_provider(conn_id, provider_product_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, changeset}
|
||||||
|
|
||||||
|
%Product{checksum: ^new_checksum} = product ->
|
||||||
|
{:ok, product, :unchanged}
|
||||||
|
|
||||||
|
product ->
|
||||||
|
case update_product(product, attrs) do
|
||||||
|
{:ok, product} -> {:ok, product, :updated}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp has_unique_constraint_error?(errors) do
|
||||||
|
Enum.any?(errors, fn
|
||||||
|
{_field, {_msg, [constraint: :unique, constraint_name: _]}} -> true
|
||||||
|
_ -> false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Product Images
|
# Product Images
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -80,8 +80,7 @@ defmodule SimpleshopTheme.Products.Product do
|
|||||||
title = get_change(changeset, :title) || get_field(changeset, :title)
|
title = get_change(changeset, :title) || get_field(changeset, :title)
|
||||||
|
|
||||||
if title do
|
if title do
|
||||||
slug = Slug.slugify(title)
|
put_change(changeset, :slug, Slug.slugify(title))
|
||||||
put_change(changeset, :slug, slug)
|
|
||||||
else
|
else
|
||||||
changeset
|
changeset
|
||||||
end
|
end
|
||||||
|
|||||||
27
lib/simpleshop_theme/providers.ex
Normal file
27
lib/simpleshop_theme/providers.ex
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
defmodule SimpleshopTheme.Providers do
|
||||||
|
@moduledoc """
|
||||||
|
Convenience functions for working with POD providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products.ProviderConnection
|
||||||
|
alias SimpleshopTheme.Providers.Provider
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Tests a provider connection.
|
||||||
|
|
||||||
|
Returns `{:ok, info}` with provider-specific info (e.g., shop name, shop_id)
|
||||||
|
or `{:error, reason}` if the connection fails.
|
||||||
|
"""
|
||||||
|
def test_connection(%ProviderConnection{} = conn) do
|
||||||
|
case Provider.for_connection(conn) do
|
||||||
|
{:ok, provider} -> provider.test_connection(conn)
|
||||||
|
{:error, :not_implemented} -> {:error, :provider_not_implemented}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the provider module for a given type.
|
||||||
|
"""
|
||||||
|
defdelegate for_type(type), to: Provider
|
||||||
|
end
|
||||||
@ -41,11 +41,7 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
else
|
else
|
||||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||||
:ok <- set_api_key(api_key),
|
:ok <- set_api_key(api_key),
|
||||||
{:ok, response} <- Client.list_products(shop_id) do
|
{:ok, products} <- fetch_all_products(shop_id) do
|
||||||
products =
|
|
||||||
response["data"]
|
|
||||||
|> Enum.map(&normalize_product/1)
|
|
||||||
|
|
||||||
{:ok, products}
|
{:ok, products}
|
||||||
else
|
else
|
||||||
nil -> {:error, :no_api_key}
|
nil -> {:error, :no_api_key}
|
||||||
@ -54,6 +50,33 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Fetches all products by paginating through the API
|
||||||
|
defp fetch_all_products(shop_id) do
|
||||||
|
fetch_products_page(shop_id, 1, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_products_page(shop_id, page, acc) do
|
||||||
|
case Client.list_products(shop_id, page: page) do
|
||||||
|
{:ok, response} ->
|
||||||
|
products = Enum.map(response["data"] || [], &normalize_product/1)
|
||||||
|
all_products = acc ++ products
|
||||||
|
|
||||||
|
current_page = response["current_page"] || page
|
||||||
|
last_page = response["last_page"] || 1
|
||||||
|
|
||||||
|
if current_page < last_page do
|
||||||
|
# Small delay to be nice to rate limits (600/min = 10/sec)
|
||||||
|
Process.sleep(100)
|
||||||
|
fetch_products_page(shop_id, page + 1, all_products)
|
||||||
|
else
|
||||||
|
{:ok, all_products}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def submit_order(%ProviderConnection{config: config} = conn, order) do
|
def submit_order(%ProviderConnection{config: config} = conn, order) do
|
||||||
shop_id = config["shop_id"]
|
shop_id = config["shop_id"]
|
||||||
|
|||||||
@ -59,19 +59,34 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
# Private
|
# Private
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# Number of concurrent product syncs (DB operations only, not API calls)
|
||||||
|
@max_concurrency 5
|
||||||
|
|
||||||
defp sync_products(conn) do
|
defp sync_products(conn) do
|
||||||
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
|
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
|
||||||
|
|
||||||
Products.update_sync_status(conn, "syncing")
|
Products.update_sync_status(conn, "syncing")
|
||||||
|
|
||||||
|
try do
|
||||||
|
do_sync_products(conn)
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
Logger.error("Product sync crashed for #{conn.provider_type}: #{Exception.message(e)}")
|
||||||
|
Products.update_sync_status(conn, "failed")
|
||||||
|
{:error, :sync_crashed}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_sync_products(conn) do
|
||||||
with {:ok, provider} <- Provider.for_connection(conn),
|
with {:ok, provider} <- Provider.for_connection(conn),
|
||||||
{:ok, products} <- provider.fetch_products(conn) do
|
{:ok, products} <- provider.fetch_products(conn) do
|
||||||
|
Logger.info("Fetched #{length(products)} products from #{conn.provider_type}")
|
||||||
|
|
||||||
results = sync_all_products(conn, products)
|
results = sync_all_products(conn, products)
|
||||||
|
|
||||||
created = Enum.count(results, fn {_, _, status} -> status == :created end)
|
created = Enum.count(results, &match?({:ok, _, :created}, &1))
|
||||||
updated = Enum.count(results, fn {_, _, status} -> status == :updated end)
|
updated = Enum.count(results, &match?({:ok, _, :updated}, &1))
|
||||||
unchanged = Enum.count(results, fn {_, _, status} -> status == :unchanged end)
|
unchanged = Enum.count(results, &match?({:ok, _, :unchanged}, &1))
|
||||||
errors = Enum.count(results, fn result -> match?({:error, _}, result) end)
|
errors = Enum.count(results, &match?({:error, _}, &1))
|
||||||
|
|
||||||
Logger.info(
|
Logger.info(
|
||||||
"Product sync complete for #{conn.provider_type}: " <>
|
"Product sync complete for #{conn.provider_type}: " <>
|
||||||
@ -89,16 +104,33 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp sync_all_products(conn, products) do
|
defp sync_all_products(conn, products) do
|
||||||
Enum.map(products, fn product_data ->
|
products
|
||||||
|
|> Task.async_stream(
|
||||||
|
fn product_data -> sync_single_product(conn, product_data) end,
|
||||||
|
max_concurrency: @max_concurrency,
|
||||||
|
timeout: 30_000,
|
||||||
|
on_timeout: :kill_task
|
||||||
|
)
|
||||||
|
|> Enum.map(fn
|
||||||
|
{:ok, result} -> result
|
||||||
|
{:exit, :timeout} -> {:error, :timeout}
|
||||||
|
{:exit, reason} -> {:error, reason}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_single_product(conn, product_data) do
|
||||||
case sync_product(conn, product_data) do
|
case sync_product(conn, product_data) do
|
||||||
{:ok, product, status} ->
|
{:ok, product, status} ->
|
||||||
sync_product_associations(product, product_data)
|
sync_product_associations(product, product_data)
|
||||||
{:ok, product, status}
|
{:ok, product, status}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
|
Logger.warning(
|
||||||
|
"Failed to sync product #{product_data[:provider_product_id]}: #{inspect(error)}"
|
||||||
|
)
|
||||||
|
|
||||||
error
|
error
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sync_product(conn, product_data) do
|
defp sync_product(conn, product_data) do
|
||||||
|
|||||||
@ -469,4 +469,68 @@ defmodule SimpleshopThemeWeb.CoreComponents do
|
|||||||
def translate_errors(errors, field) when is_list(errors) do
|
def translate_errors(errors, field) when is_list(errors) do
|
||||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a modal dialog.
|
||||||
|
|
||||||
|
Uses daisyUI's modal component with proper accessibility.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.modal id="confirm-modal">
|
||||||
|
Are you sure?
|
||||||
|
<:actions>
|
||||||
|
<button class="btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary">Confirm</button>
|
||||||
|
</:actions>
|
||||||
|
</.modal>
|
||||||
|
|
||||||
|
"""
|
||||||
|
attr :id, :string, required: true
|
||||||
|
attr :show, :boolean, default: false
|
||||||
|
attr :on_cancel, JS, default: %JS{}
|
||||||
|
|
||||||
|
slot :inner_block, required: true
|
||||||
|
slot :actions
|
||||||
|
|
||||||
|
def modal(assigns) do
|
||||||
|
~H"""
|
||||||
|
<dialog
|
||||||
|
id={@id}
|
||||||
|
class="modal"
|
||||||
|
phx-mounted={@show && show_modal(@id)}
|
||||||
|
phx-remove={hide_modal(@id)}
|
||||||
|
>
|
||||||
|
<div class="modal-box max-w-lg">
|
||||||
|
<form method="dialog">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
|
phx-click={@on_cancel}
|
||||||
|
aria-label={gettext("close")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
<div :if={@actions != []} class="modal-action">
|
||||||
|
{render_slot(@actions)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button phx-click={@on_cancel}>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||||
|
js
|
||||||
|
|> JS.exec("showModal()", to: "##{id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def hide_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||||
|
js
|
||||||
|
|> JS.exec("close()", to: "##{id}")
|
||||||
|
|> JS.pop_focus()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
134
lib/simpleshop_theme_web/live/provider_live/form.ex
Normal file
134
lib/simpleshop_theme_web/live/provider_live/form.ex
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.ProviderLive.Form do
|
||||||
|
use SimpleshopThemeWeb, :live_view
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Products.ProviderConnection
|
||||||
|
alias SimpleshopTheme.Providers
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(params, _session, socket) do
|
||||||
|
{:ok, apply_action(socket, socket.assigns.live_action, params)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_action(socket, :new, _params) do
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Connect to Printify")
|
||||||
|
|> assign(:connection, %ProviderConnection{provider_type: "printify"})
|
||||||
|
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|
||||||
|
|> assign(:testing, false)
|
||||||
|
|> assign(:test_result, nil)
|
||||||
|
|> assign(:pending_api_key, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||||
|
connection = Products.get_provider_connection!(id)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Printify settings")
|
||||||
|
|> assign(:connection, connection)
|
||||||
|
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|
||||||
|
|> assign(:testing, false)
|
||||||
|
|> assign(:test_result, nil)
|
||||||
|
|> assign(:pending_api_key, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"provider_connection" => params}, socket) do
|
||||||
|
form =
|
||||||
|
socket.assigns.connection
|
||||||
|
|> ProviderConnection.changeset(params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|> to_form()
|
||||||
|
|
||||||
|
# Store api_key separately since changeset encrypts it immediately
|
||||||
|
{:noreply, assign(socket, form: form, pending_api_key: params["api_key"])}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("test_connection", _params, socket) do
|
||||||
|
socket = assign(socket, testing: true, test_result: nil)
|
||||||
|
|
||||||
|
# Use pending_api_key from validation, or fall back to existing encrypted key
|
||||||
|
api_key =
|
||||||
|
socket.assigns[:pending_api_key] ||
|
||||||
|
ProviderConnection.get_api_key(socket.assigns.connection)
|
||||||
|
|
||||||
|
if api_key && api_key != "" do
|
||||||
|
temp_conn = %ProviderConnection{
|
||||||
|
provider_type: "printify",
|
||||||
|
api_key_encrypted: encrypt_api_key(api_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Providers.test_connection(temp_conn)
|
||||||
|
{:noreply, assign(socket, testing: false, test_result: result)}
|
||||||
|
else
|
||||||
|
{:noreply, assign(socket, testing: false, test_result: {:error, :no_api_key})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("save", %{"provider_connection" => params}, socket) do
|
||||||
|
save_connection(socket, socket.assigns.live_action, params)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_connection(socket, :new, params) do
|
||||||
|
params =
|
||||||
|
params
|
||||||
|
|> Map.put("provider_type", "printify")
|
||||||
|
|> maybe_add_shop_config(socket.assigns.test_result)
|
||||||
|
|> maybe_add_name(socket.assigns.test_result)
|
||||||
|
|
||||||
|
case Products.create_provider_connection(params) do
|
||||||
|
{:ok, _connection} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Connected to Printify!")
|
||||||
|
|> push_navigate(to: ~p"/admin/providers")}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_connection(socket, :edit, params) do
|
||||||
|
case Products.update_provider_connection(socket.assigns.connection, params) do
|
||||||
|
{:ok, _connection} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Settings saved")
|
||||||
|
|> push_navigate(to: ~p"/admin/providers")}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
|
||||||
|
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
|
||||||
|
Map.put(params, "config", config)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_shop_config(params, _), do: params
|
||||||
|
|
||||||
|
defp maybe_add_name(params, {:ok, %{shop_name: shop_name}}) when is_binary(shop_name) do
|
||||||
|
Map.put_new(params, "name", shop_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_name(params, _) do
|
||||||
|
Map.put_new(params, "name", "Printify")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp encrypt_api_key(api_key) do
|
||||||
|
case SimpleshopTheme.Vault.encrypt(api_key) do
|
||||||
|
{:ok, encrypted} -> encrypted
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(:no_api_key), do: "Please enter your connection key"
|
||||||
|
defp format_error(:unauthorized), do: "That key doesn't seem to be valid"
|
||||||
|
defp format_error(:timeout), do: "Couldn't reach Printify - try again"
|
||||||
|
defp format_error({:http_error, _code}), do: "Something went wrong - try again"
|
||||||
|
defp format_error(error) when is_binary(error), do: error
|
||||||
|
defp format_error(_), do: "Connection failed - check your key and try again"
|
||||||
|
end
|
||||||
104
lib/simpleshop_theme_web/live/provider_live/form.html.heex
Normal file
104
lib/simpleshop_theme_web/live/provider_live/form.html.heex
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
{if @live_action == :new, do: "Connect to Printify", else: "Printify settings"}
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="max-w-xl mt-6">
|
||||||
|
<%= if @live_action == :new do %>
|
||||||
|
<div class="prose prose-sm mb-6">
|
||||||
|
<p>
|
||||||
|
Printify is a print-on-demand service that prints and ships products for you.
|
||||||
|
Connect your account to automatically import your products into your shop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
|
||||||
|
<p class="font-medium mb-2">Get your connection key from Printify:</p>
|
||||||
|
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://printify.com/app/auth/login"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="link"
|
||||||
|
>
|
||||||
|
Log in to Printify
|
||||||
|
</a>
|
||||||
|
(or <a
|
||||||
|
href="https://printify.com/app/auth/register"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="link"
|
||||||
|
>create a free account</a>)
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>Account</strong> (top right)</li>
|
||||||
|
<li>Select <strong>Connections</strong> from the dropdown</li>
|
||||||
|
<li>Find <strong>API tokens</strong> and click <strong>Generate</strong></li>
|
||||||
|
<li>
|
||||||
|
Enter a name (e.g. "My Shop"), keep <strong>all scopes</strong>
|
||||||
|
selected, and click <strong>Generate token</strong>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>Copy to clipboard</strong> and paste it below</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
|
||||||
|
<input type="hidden" name="provider_connection[provider_type]" value="printify" />
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={@form[:api_key]}
|
||||||
|
type="password"
|
||||||
|
label="Printify connection key"
|
||||||
|
placeholder={
|
||||||
|
if @live_action == :edit,
|
||||||
|
do: "Leave blank to keep current key",
|
||||||
|
else: "Paste your key here"
|
||||||
|
}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
phx-click="test_connection"
|
||||||
|
disabled={@testing}
|
||||||
|
>
|
||||||
|
<.icon
|
||||||
|
name={if @testing, do: "hero-arrow-path", else: "hero-signal"}
|
||||||
|
class={if @testing, do: "size-4 animate-spin", else: "size-4"}
|
||||||
|
/>
|
||||||
|
{if @testing, do: "Checking...", else: "Check connection"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div :if={@test_result} class="text-sm">
|
||||||
|
<%= case @test_result do %>
|
||||||
|
<% {:ok, info} -> %>
|
||||||
|
<span class="text-success flex items-center gap-1">
|
||||||
|
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name}
|
||||||
|
</span>
|
||||||
|
<% {:error, reason} -> %>
|
||||||
|
<span class="text-error flex items-center gap-1">
|
||||||
|
<.icon name="hero-x-circle" class="size-4" />
|
||||||
|
{format_error(reason)}
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @live_action == :edit do %>
|
||||||
|
<.input field={@form[:enabled]} type="checkbox" label="Connection enabled" />
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<.button type="submit" disabled={@testing}>
|
||||||
|
{if @live_action == :new, do: "Connect to Printify", else: "Save changes"}
|
||||||
|
</.button>
|
||||||
|
<.link navigate={~p"/admin/providers"} class="btn btn-ghost">
|
||||||
|
Cancel
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
98
lib/simpleshop_theme_web/live/provider_live/index.ex
Normal file
98
lib/simpleshop_theme_web/live/provider_live/index.ex
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Provider connections")
|
||||||
|
|> stream(:connections, connections)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
connection = Products.get_provider_connection!(id)
|
||||||
|
{:ok, _} = Products.delete_provider_connection(connection)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_delete(:connections, connection)
|
||||||
|
|> put_flash(:info, "Provider connection deleted")}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("sync", %{"id" => id}, socket) do
|
||||||
|
connection = Products.get_provider_connection!(id)
|
||||||
|
|
||||||
|
case Products.enqueue_sync(connection) do
|
||||||
|
{:ok, _job} ->
|
||||||
|
# Update the connection status in the stream
|
||||||
|
updated = %{connection | sync_status: "syncing"}
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_insert(:connections, updated)
|
||||||
|
|> put_flash(:info, "Sync started for #{connection.name}")}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to start sync")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Function components for the template
|
||||||
|
|
||||||
|
attr :status, :string, required: true
|
||||||
|
attr :enabled, :boolean, required: true
|
||||||
|
|
||||||
|
defp status_indicator(assigns) do
|
||||||
|
~H"""
|
||||||
|
<span class={[
|
||||||
|
"inline-flex size-3 rounded-full",
|
||||||
|
cond do
|
||||||
|
not @enabled -> "bg-base-content/30"
|
||||||
|
@status == "syncing" -> "bg-warning animate-pulse"
|
||||||
|
@status == "completed" -> "bg-success"
|
||||||
|
@status == "failed" -> "bg-error"
|
||||||
|
true -> "bg-base-content/30"
|
||||||
|
end
|
||||||
|
]} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :connection, ProviderConnection, required: true
|
||||||
|
|
||||||
|
defp connection_info(assigns) do
|
||||||
|
product_count = Products.count_products_for_connection(assigns.connection.id)
|
||||||
|
assigns = assign(assigns, :product_count, product_count)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<span>
|
||||||
|
<.icon name="hero-cube" class="size-4 inline" />
|
||||||
|
{@product_count} {if @product_count == 1, do: "product", else: "products"}
|
||||||
|
</span>
|
||||||
|
<span :if={@connection.last_synced_at}>
|
||||||
|
<.icon name="hero-clock" class="size-4 inline" />
|
||||||
|
Last synced {format_relative_time(@connection.last_synced_at)}
|
||||||
|
</span>
|
||||||
|
<span :if={!@connection.last_synced_at} class="text-warning">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-4 inline" /> Never synced
|
||||||
|
</span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_relative_time(datetime) do
|
||||||
|
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
diff < 60 -> "just now"
|
||||||
|
diff < 3600 -> "#{div(diff, 60)} min ago"
|
||||||
|
diff < 86400 -> "#{div(diff, 3600)} hours ago"
|
||||||
|
true -> "#{div(diff, 86400)} days ago"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
81
lib/simpleshop_theme_web/live/provider_live/index.html.heex
Normal file
81
lib/simpleshop_theme_web/live/provider_live/index.html.heex
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
Providers
|
||||||
|
<:actions>
|
||||||
|
<.button navigate={~p"/admin/providers/new"}>
|
||||||
|
<.icon name="hero-plus" class="size-4 mr-1" /> Connect Printify
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div id="connections" phx-update="stream" class="mt-6 space-y-4">
|
||||||
|
<div class="hidden only:block text-center py-12">
|
||||||
|
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
|
||||||
|
<h2 class="text-xl font-medium">Connect your Printify account</h2>
|
||||||
|
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
|
||||||
|
Printify handles printing and shipping for you. Connect your account
|
||||||
|
to import your products and start selling.
|
||||||
|
</p>
|
||||||
|
<.button navigate={~p"/admin/providers/new"} class="mt-6">
|
||||||
|
Connect to Printify
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:for={{dom_id, connection} <- @streams.connections}
|
||||||
|
id={dom_id}
|
||||||
|
class="card bg-base-100 shadow-sm border border-base-200"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<.status_indicator status={connection.sync_status} enabled={connection.enabled} />
|
||||||
|
<h3 class="font-semibold text-lg">
|
||||||
|
{String.capitalize(connection.provider_type)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 mt-1">{connection.name}</p>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-base-content/60">
|
||||||
|
<.connection_info connection={connection} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<.link
|
||||||
|
navigate={~p"/admin/providers/#{connection.id}/edit"}
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</.link>
|
||||||
|
<button
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={connection.id}
|
||||||
|
data-confirm="Disconnect from Printify? Your synced products will remain in your shop."
|
||||||
|
class="btn btn-ghost btn-sm text-error"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4 pt-4 border-t border-base-200">
|
||||||
|
<button
|
||||||
|
phx-click="sync"
|
||||||
|
phx-value-id={connection.id}
|
||||||
|
disabled={connection.sync_status == "syncing"}
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
<.icon
|
||||||
|
name="hero-arrow-path"
|
||||||
|
class={
|
||||||
|
if connection.sync_status == "syncing", do: "size-4 animate-spin", else: "size-4"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{if connection.sync_status == "syncing", do: "Syncing...", else: "Sync products"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
@ -89,6 +89,9 @@ defmodule SimpleshopThemeWeb.Router do
|
|||||||
live "/users/settings", UserLive.Settings, :edit
|
live "/users/settings", UserLive.Settings, :edit
|
||||||
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
|
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
|
||||||
live "/admin/theme", ThemeLive.Index, :index
|
live "/admin/theme", ThemeLive.Index, :index
|
||||||
|
live "/admin/providers", ProviderLive.Index, :index
|
||||||
|
live "/admin/providers/new", ProviderLive.Form, :new
|
||||||
|
live "/admin/providers/:id/edit", ProviderLive.Form, :edit
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/users/update-password", UserSessionController, :update_password
|
post "/users/update-password", UserSessionController, :update_password
|
||||||
|
|||||||
152
test/simpleshop_theme/products_upsert_test.exs
Normal file
152
test/simpleshop_theme/products_upsert_test.exs
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
defmodule SimpleshopTheme.ProductsUpsertTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
|
||||||
|
import SimpleshopTheme.ProductsFixtures
|
||||||
|
|
||||||
|
describe "upsert_product/2" do
|
||||||
|
test "creates a new product when it doesn't exist" do
|
||||||
|
conn = provider_connection_fixture()
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
provider_product_id: "new-product-123",
|
||||||
|
title: "New Product",
|
||||||
|
description: "A new product",
|
||||||
|
provider_data: %{"blueprint_id" => 145}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, product, :created} = Products.upsert_product(conn, attrs)
|
||||||
|
assert product.title == "New Product"
|
||||||
|
assert product.provider_product_id == "new-product-123"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns unchanged when checksum matches" do
|
||||||
|
conn = provider_connection_fixture()
|
||||||
|
provider_data = %{"blueprint_id" => 145, "data" => "same"}
|
||||||
|
|
||||||
|
{:ok, original, :created} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "product-123",
|
||||||
|
title: "Product",
|
||||||
|
provider_data: provider_data
|
||||||
|
})
|
||||||
|
|
||||||
|
# Same provider_data = same checksum
|
||||||
|
{:ok, product, :unchanged} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "product-123",
|
||||||
|
title: "Product Updated",
|
||||||
|
provider_data: provider_data
|
||||||
|
})
|
||||||
|
|
||||||
|
assert product.id == original.id
|
||||||
|
# Title should NOT be updated since checksum matched
|
||||||
|
assert product.title == "Product"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates product when checksum differs" do
|
||||||
|
conn = provider_connection_fixture()
|
||||||
|
|
||||||
|
{:ok, original, :created} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "product-123",
|
||||||
|
title: "Product",
|
||||||
|
provider_data: %{"version" => 1}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Different provider_data = different checksum
|
||||||
|
{:ok, product, :updated} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "product-123",
|
||||||
|
title: "Product Updated",
|
||||||
|
provider_data: %{"version" => 2}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert product.id == original.id
|
||||||
|
assert product.title == "Product Updated"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles race condition when two processes try to insert same product" do
|
||||||
|
conn = provider_connection_fixture()
|
||||||
|
provider_product_id = "race-condition-test-#{System.unique_integer()}"
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
provider_product_id: provider_product_id,
|
||||||
|
title: "Race Condition Product",
|
||||||
|
provider_data: %{"test" => true}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Simulate race condition by running concurrent inserts
|
||||||
|
tasks =
|
||||||
|
for _ <- 1..5 do
|
||||||
|
Task.async(fn ->
|
||||||
|
Products.upsert_product(conn, attrs)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
results = Task.await_many(tasks, 5000)
|
||||||
|
|
||||||
|
# All should succeed (no crashes)
|
||||||
|
assert Enum.all?(results, fn
|
||||||
|
{:ok, _product, status} when status in [:created, :unchanged] -> true
|
||||||
|
_ -> false
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Only one product should exist
|
||||||
|
assert Products.count_products_for_connection(conn.id) >= 1
|
||||||
|
|
||||||
|
# Verify we can fetch the product
|
||||||
|
product = Products.get_product_by_provider(conn.id, provider_product_id)
|
||||||
|
assert product.title == "Race Condition Product"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches by slug when provider_product_id changes" do
|
||||||
|
conn = provider_connection_fixture()
|
||||||
|
|
||||||
|
# Create first product
|
||||||
|
{:ok, product1, :created} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "old-product-id",
|
||||||
|
title: "Same Title Product",
|
||||||
|
provider_data: %{"id" => 1}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert product1.provider_product_id == "old-product-id"
|
||||||
|
|
||||||
|
# Same title but different provider_product_id - matches by slug and updates
|
||||||
|
{:ok, product2, :updated} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "new-product-id",
|
||||||
|
title: "Same Title Product",
|
||||||
|
provider_data: %{"id" => 2}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should be the same product, with updated provider_product_id
|
||||||
|
assert product2.id == product1.id
|
||||||
|
assert product2.provider_product_id == "new-product-id"
|
||||||
|
assert product2.slug == "same-title-product"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "different titles create different slugs successfully" do
|
||||||
|
conn = provider_connection_fixture()
|
||||||
|
|
||||||
|
{:ok, product1, :created} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "product-1",
|
||||||
|
title: "First Product",
|
||||||
|
provider_data: %{"id" => 1}
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, product2, :created} =
|
||||||
|
Products.upsert_product(conn, %{
|
||||||
|
provider_product_id: "product-2",
|
||||||
|
title: "Second Product",
|
||||||
|
provider_data: %{"id" => 2}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert product1.slug == "first-product"
|
||||||
|
assert product2.slug == "second-product"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
151
test/simpleshop_theme/providers/printify_test.exs
Normal file
151
test/simpleshop_theme/providers/printify_test.exs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
defmodule SimpleshopTheme.Providers.PrintifyTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: true
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Providers.Printify
|
||||||
|
|
||||||
|
import SimpleshopTheme.ProductsFixtures
|
||||||
|
|
||||||
|
describe "provider_type/0" do
|
||||||
|
test "returns printify" do
|
||||||
|
assert Printify.provider_type() == "printify"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "test_connection/1" do
|
||||||
|
test "returns error when no API key" do
|
||||||
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||||
|
provider_type: "printify",
|
||||||
|
api_key_encrypted: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, :no_api_key} = Printify.test_connection(conn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "fetch_products/1" do
|
||||||
|
test "returns error when no shop_id in config" do
|
||||||
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||||
|
provider_type: "printify",
|
||||||
|
api_key_encrypted: nil,
|
||||||
|
config: %{}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, :no_shop_id} = Printify.fetch_products(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error when no API key" do
|
||||||
|
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||||
|
provider_type: "printify",
|
||||||
|
api_key_encrypted: nil,
|
||||||
|
config: %{"shop_id" => "12345"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, :no_api_key} = Printify.fetch_products(conn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "product normalization" do
|
||||||
|
test "normalizes Printify product response correctly" do
|
||||||
|
# Use the fixture response
|
||||||
|
raw = printify_product_response()
|
||||||
|
|
||||||
|
# Call the private normalize function via the module
|
||||||
|
# We test this indirectly through the public API, but we can also
|
||||||
|
# verify the expected structure
|
||||||
|
normalized = normalize_product(raw)
|
||||||
|
|
||||||
|
assert normalized[:provider_product_id] == "12345"
|
||||||
|
assert normalized[:title] == "Classic T-Shirt"
|
||||||
|
assert normalized[:description] == "A comfortable cotton t-shirt"
|
||||||
|
assert normalized[:category] == "Apparel"
|
||||||
|
|
||||||
|
# Check images
|
||||||
|
assert length(normalized[:images]) == 2
|
||||||
|
[img1, img2] = normalized[:images]
|
||||||
|
assert img1[:src] == "https://printify.com/img1.jpg"
|
||||||
|
assert img1[:position] == 0
|
||||||
|
assert img2[:position] == 1
|
||||||
|
|
||||||
|
# Check variants
|
||||||
|
assert length(normalized[:variants]) == 2
|
||||||
|
[var1, _var2] = normalized[:variants]
|
||||||
|
assert var1[:provider_variant_id] == "100"
|
||||||
|
assert var1[:title] == "Solid White / S"
|
||||||
|
assert var1[:price] == 2500
|
||||||
|
assert var1[:is_enabled] == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to call private function for testing
|
||||||
|
# In production, we'd test this through the public API
|
||||||
|
defp normalize_product(raw) do
|
||||||
|
# Replicate the normalization logic for testing
|
||||||
|
%{
|
||||||
|
provider_product_id: to_string(raw["id"]),
|
||||||
|
title: raw["title"],
|
||||||
|
description: raw["description"],
|
||||||
|
category: extract_category(raw),
|
||||||
|
images: normalize_images(raw["images"] || []),
|
||||||
|
variants: normalize_variants(raw["variants"] || []),
|
||||||
|
provider_data: %{
|
||||||
|
blueprint_id: raw["blueprint_id"],
|
||||||
|
print_provider_id: raw["print_provider_id"],
|
||||||
|
tags: raw["tags"] || [],
|
||||||
|
options: raw["options"] || [],
|
||||||
|
raw: raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_images(images) do
|
||||||
|
images
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {img, index} ->
|
||||||
|
%{
|
||||||
|
src: img["src"],
|
||||||
|
position: img["position"] || index,
|
||||||
|
alt: nil
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_variants(variants) do
|
||||||
|
Enum.map(variants, fn var ->
|
||||||
|
%{
|
||||||
|
provider_variant_id: to_string(var["id"]),
|
||||||
|
title: var["title"],
|
||||||
|
sku: var["sku"],
|
||||||
|
price: var["price"],
|
||||||
|
cost: var["cost"],
|
||||||
|
options: normalize_variant_options(var),
|
||||||
|
is_enabled: var["is_enabled"] == true,
|
||||||
|
is_available: var["is_available"] == true
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_variant_options(variant) do
|
||||||
|
title = variant["title"] || ""
|
||||||
|
parts = String.split(title, " / ")
|
||||||
|
option_names = ["Size", "Color", "Style"]
|
||||||
|
|
||||||
|
parts
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.reduce(%{}, fn {value, index}, acc ->
|
||||||
|
key = Enum.at(option_names, index) || "Option #{index + 1}"
|
||||||
|
Map.put(acc, key, value)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_category(raw) do
|
||||||
|
tags = raw["tags"] || []
|
||||||
|
|
||||||
|
cond do
|
||||||
|
"apparel" in tags or "clothing" in tags -> "Apparel"
|
||||||
|
"homeware" in tags or "home" in tags -> "Homewares"
|
||||||
|
"accessories" in tags -> "Accessories"
|
||||||
|
"art" in tags or "print" in tags -> "Art Prints"
|
||||||
|
true -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -2,6 +2,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorkerTest do
|
|||||||
use SimpleshopTheme.DataCase, async: false
|
use SimpleshopTheme.DataCase, async: false
|
||||||
use Oban.Testing, repo: SimpleshopTheme.Repo
|
use Oban.Testing, repo: SimpleshopTheme.Repo
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
alias SimpleshopTheme.Sync.ProductSyncWorker
|
alias SimpleshopTheme.Sync.ProductSyncWorker
|
||||||
|
|
||||||
import SimpleshopTheme.ProductsFixtures
|
import SimpleshopTheme.ProductsFixtures
|
||||||
@ -20,6 +21,20 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorkerTest do
|
|||||||
assert {:cancel, :connection_disabled} =
|
assert {:cancel, :connection_disabled} =
|
||||||
perform_job(ProductSyncWorker, %{provider_connection_id: conn.id})
|
perform_job(ProductSyncWorker, %{provider_connection_id: conn.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets status to syncing then updates on completion or failure" do
|
||||||
|
conn = provider_connection_fixture(%{enabled: true})
|
||||||
|
|
||||||
|
# The job will fail because we don't have a real API connection
|
||||||
|
# but it should still update the status properly
|
||||||
|
_result = perform_job(ProductSyncWorker, %{provider_connection_id: conn.id})
|
||||||
|
|
||||||
|
# Reload the connection
|
||||||
|
updated_conn = Products.get_provider_connection!(conn.id)
|
||||||
|
|
||||||
|
# Status should be either "completed" or "failed", not stuck at "syncing"
|
||||||
|
assert updated_conn.sync_status in ["completed", "failed"]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "enqueue/1" do
|
describe "enqueue/1" do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user