Extract Products.connect_provider/2 that tests the connection, fetches shop_id, creates the record, and enqueues sync. Both the setup wizard and the providers form now use this shared function instead of duplicating the flow. Also makes the products empty state context-aware (distinguishes "no provider" from "provider connected but no products"). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1036 lines
30 KiB
Elixir
1036 lines
30 KiB
Elixir
defmodule Berrypod.Products do
|
|
@moduledoc """
|
|
The Products context.
|
|
|
|
Manages products synced from POD providers, including provider connections,
|
|
products, images, and variants.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
alias Berrypod.Repo
|
|
alias Berrypod.Products.{ProviderConnection, Product, ProductImage, ProductVariant}
|
|
|
|
# =============================================================================
|
|
# Provider Connections
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Returns the list of provider connections.
|
|
"""
|
|
def list_provider_connections do
|
|
Repo.all(ProviderConnection)
|
|
end
|
|
|
|
@doc """
|
|
Gets a single provider connection.
|
|
"""
|
|
def get_provider_connection(id) do
|
|
Repo.get(ProviderConnection, id)
|
|
end
|
|
|
|
@doc """
|
|
Gets a single provider connection, raising if not found.
|
|
"""
|
|
def get_provider_connection!(id) do
|
|
Repo.get!(ProviderConnection, id)
|
|
end
|
|
|
|
@doc """
|
|
Gets a provider connection by type.
|
|
"""
|
|
def get_provider_connection_by_type(provider_type) do
|
|
Repo.get_by(ProviderConnection, provider_type: provider_type)
|
|
end
|
|
|
|
@doc """
|
|
Returns the first provider connection with an API key, or nil.
|
|
|
|
Provider-agnostic — doesn't care which type. Ordered by creation date.
|
|
"""
|
|
def get_first_provider_connection do
|
|
ProviderConnection
|
|
|> where([c], not is_nil(c.api_key_encrypted))
|
|
|> order_by(:inserted_at)
|
|
|> limit(1)
|
|
|> Repo.one()
|
|
end
|
|
|
|
@doc """
|
|
Creates a provider connection.
|
|
"""
|
|
def create_provider_connection(attrs \\ %{}) do
|
|
%ProviderConnection{}
|
|
|> ProviderConnection.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Tests an API key, creates the connection with config (shop_id etc),
|
|
and enqueues a product sync. Used by both setup wizard and providers form.
|
|
|
|
Returns `{:ok, connection}` or `{:error, reason}`.
|
|
"""
|
|
def connect_provider(api_key, provider_type) do
|
|
alias Berrypod.Providers
|
|
|
|
encrypted =
|
|
case Berrypod.Vault.encrypt(api_key) do
|
|
{:ok, enc} -> enc
|
|
_ -> nil
|
|
end
|
|
|
|
temp_conn = %ProviderConnection{
|
|
provider_type: provider_type,
|
|
api_key_encrypted: encrypted
|
|
}
|
|
|
|
with {:ok, test_result} <- Providers.test_connection(temp_conn) do
|
|
name = provider_display_name(provider_type, test_result)
|
|
config = provider_config(provider_type, test_result)
|
|
|
|
params =
|
|
%{"api_key" => api_key, "provider_type" => provider_type, "name" => name}
|
|
|> then(fn p -> if config != %{}, do: Map.put(p, "config", config), else: p end)
|
|
|
|
case create_provider_connection(params) do
|
|
{:ok, connection} ->
|
|
enqueue_sync(connection)
|
|
{:ok, connection}
|
|
|
|
{:error, _} = error ->
|
|
error
|
|
end
|
|
end
|
|
end
|
|
|
|
defp provider_display_name("printify", %{shop_name: name}) when is_binary(name), do: name
|
|
defp provider_display_name("printful", %{store_name: name}) when is_binary(name), do: name
|
|
|
|
defp provider_display_name(type, _) do
|
|
case Berrypod.Providers.Provider.get(type) do
|
|
nil -> type
|
|
info -> info.name
|
|
end
|
|
end
|
|
|
|
defp provider_config("printify", %{shop_id: id}), do: %{"shop_id" => to_string(id)}
|
|
defp provider_config("printful", %{store_id: id}), do: %{"store_id" => to_string(id)}
|
|
defp provider_config(_, _), do: %{}
|
|
|
|
@doc """
|
|
Updates a provider connection.
|
|
"""
|
|
def update_provider_connection(%ProviderConnection{} = conn, attrs) do
|
|
conn
|
|
|> ProviderConnection.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Deletes a provider connection.
|
|
"""
|
|
def delete_provider_connection(%ProviderConnection{} = conn) do
|
|
Repo.delete(conn)
|
|
end
|
|
|
|
@doc """
|
|
Updates the sync status of a provider connection.
|
|
"""
|
|
def update_sync_status(%ProviderConnection{} = conn, status, synced_at \\ nil) do
|
|
attrs = %{sync_status: status}
|
|
attrs = if synced_at, do: Map.put(attrs, :last_synced_at, synced_at), else: attrs
|
|
|
|
conn
|
|
|> ProviderConnection.sync_changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Resets any stale "syncing" status to "idle".
|
|
|
|
Called on application startup to recover from interrupted syncs
|
|
(e.g., node shutdown while sync was running).
|
|
"""
|
|
def reset_stale_sync_status do
|
|
from(c in ProviderConnection, where: c.sync_status == "syncing")
|
|
|> Repo.update_all(set: [sync_status: "idle"])
|
|
end
|
|
|
|
@doc """
|
|
Returns the total count of all products.
|
|
"""
|
|
def count_products do
|
|
Repo.aggregate(Product, :count)
|
|
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
|
|
Berrypod.Sync.ProductSyncWorker.enqueue(conn.id)
|
|
end
|
|
|
|
# =============================================================================
|
|
# Storefront queries
|
|
# =============================================================================
|
|
|
|
# Image preload that skips the data blob — only loads :id and :source_width
|
|
# from the images table. The data column holds the raw image binary (up to 5MB
|
|
# per row) which is never used in rendering.
|
|
defp image_preload_query do
|
|
from(i in Berrypod.Media.Image, select: struct(i, [:id, :source_width]))
|
|
end
|
|
|
|
# Listing pages only need the first 2 images (primary + hover) and don't
|
|
# need variants (price/stock are denormalized on the product row).
|
|
defp listing_preloads do
|
|
pi_query = from(pi in ProductImage, where: pi.position <= 1, order_by: pi.position)
|
|
[images: {pi_query, image: image_preload_query()}]
|
|
end
|
|
|
|
# Skip provider_data (up to 72KB JSON) and description from listing queries —
|
|
# neither is used on product cards. Same idea as image_preload_query().
|
|
@listing_fields Product.__schema__(:fields) -- [:provider_data, :description]
|
|
|
|
defp listing_select(query) do
|
|
from(p in query, select: struct(p, ^@listing_fields))
|
|
end
|
|
|
|
@doc """
|
|
Gets a single visible, active product by slug with full preloads (for detail page).
|
|
"""
|
|
def get_visible_product(slug) do
|
|
Product
|
|
|> where([p], p.slug == ^slug and p.visible == true and p.status == "active")
|
|
|> Repo.one()
|
|
|> case do
|
|
nil -> nil
|
|
product -> Repo.preload(product, images: [image: image_preload_query()], variants: [])
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Lists visible, active products with listing preloads (no variants).
|
|
|
|
## Options
|
|
|
|
* `:sort` - sort order: "price_asc", "price_desc", "newest", "name_asc", "name_desc"
|
|
* `:category` - filter by category name
|
|
* `:on_sale` - if true, only products on sale
|
|
* `:in_stock` - if true, only products in stock
|
|
* `:limit` - max number of results
|
|
* `:exclude` - product ID to exclude
|
|
|
|
"""
|
|
def list_visible_products(opts \\ []) do
|
|
Product
|
|
|> where([p], p.visible == true and p.status == "active")
|
|
|> apply_visible_filters(opts)
|
|
|> apply_sort(opts[:sort])
|
|
|> maybe_limit(opts[:limit])
|
|
|> maybe_exclude(opts[:exclude])
|
|
|> listing_select()
|
|
|> Repo.all()
|
|
|> Repo.preload(listing_preloads())
|
|
end
|
|
|
|
@doc """
|
|
Like `list_visible_products/1` but returns a `%Pagination{}` struct.
|
|
|
|
Accepts the same filter/sort options plus `:page` and `:per_page`.
|
|
"""
|
|
def list_visible_products_paginated(opts \\ []) do
|
|
pagination =
|
|
Product
|
|
|> where([p], p.visible == true and p.status == "active")
|
|
|> apply_visible_filters(opts)
|
|
|> apply_sort(opts[:sort])
|
|
|> maybe_exclude(opts[:exclude])
|
|
|> listing_select()
|
|
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
|
|
|
|
%{pagination | items: Repo.preload(pagination.items, listing_preloads())}
|
|
end
|
|
|
|
@doc """
|
|
Lists distinct categories from visible, active products.
|
|
Returns a list of `%{name, slug, image_url}` where `image_url` is the
|
|
first product image for a representative product in that category.
|
|
|
|
Results are cached in ETS and invalidated when products change.
|
|
"""
|
|
def list_categories do
|
|
alias Berrypod.Settings.SettingsCache
|
|
|
|
case SettingsCache.get_cached(:categories) do
|
|
{:ok, categories} ->
|
|
categories
|
|
|
|
:miss ->
|
|
categories = do_list_categories()
|
|
SettingsCache.put_cached(:categories, categories)
|
|
categories
|
|
end
|
|
end
|
|
|
|
# Single query: categories with their first product image (by position).
|
|
# Uses a correlated subquery to pick the lowest-position image per category,
|
|
# replacing the previous N+1 pattern.
|
|
defp do_list_categories do
|
|
# Categories that have at least one product image
|
|
with_images =
|
|
from(p in Product,
|
|
join: pi in ProductImage,
|
|
on: pi.product_id == p.id,
|
|
where: p.visible == true and p.status == "active" and not is_nil(p.category),
|
|
where:
|
|
pi.id ==
|
|
fragment(
|
|
"""
|
|
(SELECT pi2.id FROM product_images pi2
|
|
JOIN products p2 ON pi2.product_id = p2.id
|
|
WHERE p2.category = ? AND p2.visible = 1 AND p2.status = 'active'
|
|
ORDER BY pi2.position ASC LIMIT 1)
|
|
""",
|
|
p.category
|
|
),
|
|
group_by: p.category,
|
|
order_by: p.category,
|
|
select: {p.category, pi.image_id, pi.src}
|
|
)
|
|
|> Repo.all()
|
|
|
|
categories_with_images = MapSet.new(with_images, &elem(&1, 0))
|
|
|
|
# Categories without any images (still need to appear in the list)
|
|
without_images =
|
|
from(p in Product,
|
|
where: p.visible == true and p.status == "active" and not is_nil(p.category),
|
|
where: p.category not in ^MapSet.to_list(categories_with_images),
|
|
select: p.category,
|
|
distinct: true,
|
|
order_by: p.category
|
|
)
|
|
|> Repo.all()
|
|
|> Enum.map(&{&1, nil, nil})
|
|
|
|
(with_images ++ without_images)
|
|
|> Enum.sort_by(&elem(&1, 0))
|
|
|> Enum.map(fn {name, image_id, src} ->
|
|
image_url =
|
|
cond do
|
|
not is_nil(image_id) -> "/image_cache/#{image_id}-400.webp"
|
|
is_binary(src) -> src
|
|
true -> nil
|
|
end
|
|
|
|
%{name: name, slug: Slug.slugify(name), image_url: image_url}
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Recomputes denormalized fields from a product's variants.
|
|
Called after variant sync to keep cached fields up to date.
|
|
"""
|
|
def recompute_cached_fields(%Product{} = product) do
|
|
variants = Repo.all(from v in ProductVariant, where: v.product_id == ^product.id)
|
|
|
|
available = Enum.filter(variants, &(&1.is_enabled and &1.is_available))
|
|
cheapest = Enum.min_by(available, & &1.price, fn -> nil end)
|
|
|
|
attrs = %{
|
|
cheapest_price: if(cheapest, do: cheapest.price, else: 0),
|
|
compare_at_price: if(cheapest, do: cheapest.compare_at_price),
|
|
in_stock: available != [],
|
|
on_sale: Enum.any?(variants, &ProductVariant.on_sale?/1)
|
|
}
|
|
|
|
product
|
|
|> Product.recompute_changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
defp apply_visible_filters(query, opts) do
|
|
query
|
|
|> maybe_filter_category(opts[:category])
|
|
|> maybe_filter_on_sale(opts[:on_sale])
|
|
|> maybe_filter_in_stock(opts[:in_stock])
|
|
end
|
|
|
|
defp maybe_filter_category(query, nil), do: query
|
|
defp maybe_filter_category(query, name), do: where(query, [p], p.category == ^name)
|
|
|
|
defp maybe_filter_on_sale(query, true), do: where(query, [p], p.on_sale == true)
|
|
defp maybe_filter_on_sale(query, _), do: query
|
|
|
|
defp maybe_filter_in_stock(query, true), do: where(query, [p], p.in_stock == true)
|
|
defp maybe_filter_in_stock(query, false), do: where(query, [p], p.in_stock == false)
|
|
defp maybe_filter_in_stock(query, _), do: query
|
|
|
|
defp apply_sort(query, "price_asc"), do: order_by(query, [p], asc: p.cheapest_price)
|
|
defp apply_sort(query, "price_desc"), do: order_by(query, [p], desc: p.cheapest_price)
|
|
defp apply_sort(query, "newest"), do: order_by(query, [p], desc: p.inserted_at)
|
|
defp apply_sort(query, "name_asc"), do: order_by(query, [p], asc: p.title)
|
|
defp apply_sort(query, "name_desc"), do: order_by(query, [p], desc: p.title)
|
|
defp apply_sort(query, _), do: order_by(query, [p], desc: p.inserted_at)
|
|
|
|
defp maybe_limit(query, nil), do: query
|
|
defp maybe_limit(query, n) when is_integer(n), do: limit(query, ^n)
|
|
|
|
defp maybe_exclude(query, nil), do: query
|
|
defp maybe_exclude(query, id), do: where(query, [p], p.id != ^id)
|
|
|
|
# =============================================================================
|
|
# Products
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Returns the list of products.
|
|
|
|
## Options
|
|
|
|
* `:visible` - filter by visibility (boolean)
|
|
* `:status` - filter by status (string)
|
|
* `:category` - filter by category (string)
|
|
* `:provider_connection_id` - filter by provider connection
|
|
* `:preload` - list of associations to preload
|
|
|
|
"""
|
|
def list_products(opts \\ []) do
|
|
Product
|
|
|> apply_product_filters(opts)
|
|
|> order_by([p], desc: p.inserted_at)
|
|
|> maybe_preload(opts[:preload])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Returns products for the admin list page with sorting, stock filtering,
|
|
and full preloads for display.
|
|
|
|
## Options
|
|
|
|
* `:visible` - filter by visibility (boolean)
|
|
* `:status` - filter by status (string)
|
|
* `:category` - filter by category (string)
|
|
* `:provider_connection_id` - filter by provider connection
|
|
* `:in_stock` - filter by stock status (boolean)
|
|
* `:sort` - sort order (string)
|
|
|
|
"""
|
|
def list_products_admin(opts \\ []) do
|
|
Product
|
|
|> apply_product_filters(opts)
|
|
|> maybe_filter_in_stock(opts[:in_stock])
|
|
|> apply_sort(opts[:sort])
|
|
|> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Like `list_products_admin/1` but returns a `%Pagination{}` struct.
|
|
|
|
Accepts the same filter options plus `:page` and `:per_page`.
|
|
"""
|
|
def list_products_admin_paginated(opts \\ []) do
|
|
Product
|
|
|> apply_product_filters(opts)
|
|
|> maybe_filter_in_stock(opts[:in_stock])
|
|
|> apply_sort(opts[:sort])
|
|
|> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
|
|
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
|
|
end
|
|
|
|
@doc """
|
|
Returns distinct category names from all products (including hidden/draft).
|
|
"""
|
|
def list_all_categories do
|
|
from(p in Product,
|
|
where: not is_nil(p.category),
|
|
select: p.category,
|
|
distinct: true,
|
|
order_by: p.category
|
|
)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Gets a single product by ID.
|
|
"""
|
|
def get_product(id, opts \\ []) do
|
|
Product
|
|
|> maybe_preload(opts[:preload])
|
|
|> Repo.get(id)
|
|
end
|
|
|
|
@doc """
|
|
Gets a product by ID with admin preloads (provider, images, variants).
|
|
|
|
Excludes image blob data.
|
|
"""
|
|
def get_product_with_preloads(id) do
|
|
Product
|
|
|> Repo.get(id)
|
|
|> case do
|
|
nil ->
|
|
nil
|
|
|
|
product ->
|
|
Repo.preload(product, [
|
|
:provider_connection,
|
|
images: [image: image_preload_query()],
|
|
variants: []
|
|
])
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets a single product by slug.
|
|
"""
|
|
def get_product_by_slug(slug, opts \\ []) do
|
|
Product
|
|
|> maybe_preload(opts[:preload])
|
|
|> Repo.get_by(slug: slug)
|
|
end
|
|
|
|
@doc """
|
|
Gets a product by provider connection and provider product ID.
|
|
"""
|
|
def get_product_by_provider(provider_connection_id, provider_product_id) do
|
|
Repo.get_by(Product,
|
|
provider_connection_id: provider_connection_id,
|
|
provider_product_id: provider_product_id
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Creates a product.
|
|
"""
|
|
def create_product(attrs \\ %{}) do
|
|
%Product{}
|
|
|> Product.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Updates a product.
|
|
"""
|
|
def update_product(%Product{} = product, attrs) do
|
|
product
|
|
|> Product.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Updates storefront-only fields (visibility and category).
|
|
"""
|
|
def update_storefront(%Product{} = product, attrs) do
|
|
result =
|
|
product
|
|
|> Product.storefront_changeset(attrs)
|
|
|> Repo.update()
|
|
|
|
case result do
|
|
{:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
|
|
_ -> :ok
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
@doc """
|
|
Toggles a product's visibility.
|
|
"""
|
|
def toggle_visibility(%Product{} = product) do
|
|
update_storefront(product, %{visible: !product.visible})
|
|
end
|
|
|
|
@doc """
|
|
Deletes a product.
|
|
"""
|
|
def delete_product(%Product{} = product) do
|
|
# Create a redirect before deletion so the old URL doesn't 404
|
|
target =
|
|
if product.category do
|
|
"/collections/#{Slug.slugify(product.category)}"
|
|
else
|
|
"/"
|
|
end
|
|
|
|
Berrypod.Redirects.create_auto(%{
|
|
from_path: "/products/#{product.slug}",
|
|
to_path: target,
|
|
source: "auto_product_deleted"
|
|
})
|
|
|
|
result = Repo.delete(product)
|
|
|
|
case result do
|
|
{:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
|
|
_ -> :ok
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
@doc """
|
|
Upserts a product from provider data.
|
|
|
|
Creates a new product if one doesn't exist for the given provider connection
|
|
and provider product ID. Updates the existing product if checksum differs.
|
|
|
|
Returns `{:ok, product, :created | :updated | :unchanged}`.
|
|
"""
|
|
def upsert_product(%ProviderConnection{id: conn_id}, attrs) do
|
|
provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"]
|
|
new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"])
|
|
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
|
|
nil ->
|
|
# Not found by provider ID - check by slug (same title = same product)
|
|
slug = Slug.slugify(title)
|
|
find_by_slug_or_insert(conn_id, slug, attrs, new_checksum)
|
|
|
|
%Product{checksum: ^new_checksum} = product ->
|
|
{:ok, product, :unchanged}
|
|
|
|
product ->
|
|
old_slug = product.slug
|
|
|
|
case update_product(product, attrs) do
|
|
{:ok, updated} ->
|
|
if old_slug != updated.slug do
|
|
Berrypod.Redirects.create_auto(%{
|
|
from_path: "/products/#{old_slug}",
|
|
to_path: "/products/#{updated.slug}",
|
|
source: "auto_slug_change"
|
|
})
|
|
end
|
|
|
|
{:ok, updated, :updated}
|
|
|
|
error ->
|
|
error
|
|
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
|
|
|
|
nil ->
|
|
# Not found at all - insert new
|
|
do_insert_product(attrs)
|
|
|
|
_different_connection ->
|
|
# Slug taken by a different provider connection - make it unique
|
|
unique_slug = make_unique_slug(slug)
|
|
do_insert_product(Map.put(attrs, :slug, unique_slug))
|
|
end
|
|
end
|
|
|
|
defp make_unique_slug(base_slug, suffix \\ 2) do
|
|
candidate = "#{base_slug}-#{suffix}"
|
|
|
|
case Repo.get_by(Product, slug: candidate) do
|
|
nil -> candidate
|
|
_ -> make_unique_slug(base_slug, suffix + 1)
|
|
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
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Creates a product image.
|
|
"""
|
|
def create_product_image(attrs \\ %{}) do
|
|
%ProductImage{}
|
|
|> ProductImage.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Gets a single product image by ID.
|
|
"""
|
|
def get_product_image(id) do
|
|
Repo.get(ProductImage, id)
|
|
end
|
|
|
|
@doc """
|
|
Lists all images for a product, ordered by position.
|
|
"""
|
|
def list_product_images(product_id) do
|
|
from(i in ProductImage, where: i.product_id == ^product_id, order_by: i.position)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Updates a product image with the given attributes.
|
|
"""
|
|
def update_product_image(%ProductImage{} = product_image, attrs) do
|
|
product_image
|
|
|> ProductImage.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Links a product image to a Media.Image by setting its image_id.
|
|
"""
|
|
def link_product_image(%ProductImage{} = product_image, image_id) do
|
|
product_image
|
|
|> ProductImage.changeset(%{image_id: image_id})
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Lists product images that need downloading (have src but no image_id).
|
|
|
|
## Options
|
|
|
|
* `:limit` - maximum number of images to return (default: 100)
|
|
"""
|
|
def list_pending_downloads(opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, 100)
|
|
|
|
from(i in ProductImage,
|
|
where: not is_nil(i.src) and is_nil(i.image_id),
|
|
order_by: [asc: i.inserted_at],
|
|
limit: ^limit
|
|
)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Deletes all images for a product, including their backing Media.Image records.
|
|
"""
|
|
def delete_product_images(%Product{id: product_id}) do
|
|
image_ids =
|
|
from(pi in ProductImage,
|
|
where: pi.product_id == ^product_id and not is_nil(pi.image_id),
|
|
select: pi.image_id
|
|
)
|
|
|> Repo.all()
|
|
|
|
result =
|
|
from(pi in ProductImage, where: pi.product_id == ^product_id)
|
|
|> Repo.delete_all()
|
|
|
|
cleanup_orphaned_images(image_ids)
|
|
|
|
result
|
|
end
|
|
|
|
@doc """
|
|
Syncs product images from a list of image data.
|
|
|
|
Preserves existing image_id references when the URL hasn't changed.
|
|
Returns a list of {:ok, image} tuples for images that need downloading.
|
|
"""
|
|
def sync_product_images(%Product{id: product_id}, images) when is_list(images) do
|
|
# Build map of existing images by position
|
|
existing_by_position =
|
|
from(i in ProductImage, where: i.product_id == ^product_id)
|
|
|> Repo.all()
|
|
|> Map.new(&{&1.position, &1})
|
|
|
|
incoming_positions =
|
|
images
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {image_data, index} -> image_data[:position] || index end)
|
|
|> MapSet.new()
|
|
|
|
# Delete orphaned positions (images no longer in the list)
|
|
orphaned =
|
|
existing_by_position
|
|
|> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end)
|
|
|
|
orphaned_ids = Enum.map(orphaned, fn {_position, img} -> img.id end)
|
|
|
|
orphaned_image_ids =
|
|
orphaned
|
|
|> Enum.map(fn {_position, img} -> img.image_id end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
if orphaned_ids != [] do
|
|
from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all()
|
|
end
|
|
|
|
# Upsert incoming images, collecting image_ids displaced by URL changes
|
|
{results, replaced_image_ids} =
|
|
images
|
|
|> Enum.with_index()
|
|
|> Enum.map_reduce([], fn {image_data, index}, acc ->
|
|
position = image_data[:position] || index
|
|
src = image_data[:src]
|
|
existing = Map.get(existing_by_position, position)
|
|
|
|
cond do
|
|
# Same URL at position - update color if needed, preserve image_id
|
|
existing && existing.src == src ->
|
|
result =
|
|
if existing.color != image_data[:color] do
|
|
existing
|
|
|> ProductImage.changeset(%{color: image_data[:color]})
|
|
|> Repo.update()
|
|
else
|
|
{:ok, existing}
|
|
end
|
|
|
|
{result, acc}
|
|
|
|
# Different URL at position - update src, clear image_id (triggers re-download)
|
|
existing ->
|
|
acc = if existing.image_id, do: [existing.image_id | acc], else: acc
|
|
|
|
result =
|
|
existing
|
|
|> ProductImage.changeset(%{
|
|
src: src,
|
|
alt: image_data[:alt],
|
|
color: image_data[:color],
|
|
image_id: nil
|
|
})
|
|
|> Repo.update()
|
|
|
|
{result, acc}
|
|
|
|
# New position - create new
|
|
true ->
|
|
attrs =
|
|
image_data
|
|
|> Map.put(:product_id, product_id)
|
|
|> Map.put(:position, position)
|
|
|
|
{create_product_image(attrs), acc}
|
|
end
|
|
end)
|
|
|
|
cleanup_orphaned_images(orphaned_image_ids ++ replaced_image_ids)
|
|
|
|
results
|
|
end
|
|
|
|
# Deletes Media.Image records that are no longer referenced by any product_image.
|
|
defp cleanup_orphaned_images([]), do: :ok
|
|
|
|
defp cleanup_orphaned_images(image_ids) do
|
|
alias Berrypod.Media.Image, as: ImageSchema
|
|
|
|
from(i in ImageSchema, where: i.id in ^image_ids)
|
|
|> Repo.delete_all()
|
|
end
|
|
|
|
# =============================================================================
|
|
# Product Variants
|
|
# =============================================================================
|
|
|
|
@doc """
|
|
Gets multiple variants by their IDs with associated products and images.
|
|
|
|
Returns a map of variant_id => variant struct for efficient lookup.
|
|
Used by Cart.hydrate/1 to fetch variant data for display.
|
|
"""
|
|
def get_variants_with_products(variant_ids) when is_list(variant_ids) do
|
|
from(v in ProductVariant,
|
|
where: v.id in ^variant_ids,
|
|
preload: [product: [images: [image: ^image_preload_query()]]]
|
|
)
|
|
|> Repo.all()
|
|
|> Map.new(&{&1.id, &1})
|
|
end
|
|
|
|
@doc """
|
|
Creates a product variant.
|
|
"""
|
|
def create_product_variant(attrs \\ %{}) do
|
|
%ProductVariant{}
|
|
|> ProductVariant.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Updates a product variant.
|
|
"""
|
|
def update_product_variant(%ProductVariant{} = variant, attrs) do
|
|
variant
|
|
|> ProductVariant.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Deletes all variants for a product.
|
|
"""
|
|
def delete_product_variants(%Product{id: product_id}) do
|
|
from(v in ProductVariant, where: v.product_id == ^product_id)
|
|
|> Repo.delete_all()
|
|
end
|
|
|
|
@doc """
|
|
Gets a variant by product and provider variant ID.
|
|
"""
|
|
def get_variant_by_provider(product_id, provider_variant_id) do
|
|
Repo.get_by(ProductVariant,
|
|
product_id: product_id,
|
|
provider_variant_id: provider_variant_id
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Syncs product variants from a list of variant data.
|
|
|
|
Upserts variants based on provider_variant_id.
|
|
"""
|
|
def sync_product_variants(%Product{id: product_id}, variants) when is_list(variants) do
|
|
existing_ids =
|
|
from(v in ProductVariant,
|
|
where: v.product_id == ^product_id,
|
|
select: v.provider_variant_id
|
|
)
|
|
|> Repo.all()
|
|
|> MapSet.new()
|
|
|
|
incoming_ids =
|
|
variants
|
|
|> Enum.map(&(&1[:provider_variant_id] || &1["provider_variant_id"]))
|
|
|> MapSet.new()
|
|
|
|
# Delete variants that are no longer in the incoming list
|
|
removed_ids = MapSet.difference(existing_ids, incoming_ids)
|
|
|
|
if MapSet.size(removed_ids) > 0 do
|
|
from(v in ProductVariant,
|
|
where:
|
|
v.product_id == ^product_id and v.provider_variant_id in ^MapSet.to_list(removed_ids)
|
|
)
|
|
|> Repo.delete_all()
|
|
end
|
|
|
|
# Upsert incoming variants
|
|
Enum.map(variants, fn variant_data ->
|
|
provider_variant_id =
|
|
variant_data[:provider_variant_id] || variant_data["provider_variant_id"]
|
|
|
|
attrs = Map.put(variant_data, :product_id, product_id)
|
|
|
|
case get_variant_by_provider(product_id, provider_variant_id) do
|
|
nil ->
|
|
create_product_variant(attrs)
|
|
|
|
existing ->
|
|
update_product_variant(existing, attrs)
|
|
end
|
|
end)
|
|
end
|
|
|
|
# =============================================================================
|
|
# Private Helpers
|
|
# =============================================================================
|
|
|
|
defp apply_product_filters(query, opts) do
|
|
query
|
|
|> filter_by_visible(opts[:visible])
|
|
|> filter_by_status(opts[:status])
|
|
|> filter_by_category(opts[:category])
|
|
|> filter_by_provider_connection(opts[:provider_connection_id])
|
|
end
|
|
|
|
defp filter_by_visible(query, nil), do: query
|
|
defp filter_by_visible(query, visible), do: where(query, [p], p.visible == ^visible)
|
|
|
|
defp filter_by_status(query, nil), do: query
|
|
defp filter_by_status(query, status), do: where(query, [p], p.status == ^status)
|
|
|
|
defp filter_by_category(query, nil), do: query
|
|
defp filter_by_category(query, category), do: where(query, [p], p.category == ^category)
|
|
|
|
defp filter_by_provider_connection(query, nil), do: query
|
|
|
|
defp filter_by_provider_connection(query, conn_id),
|
|
do: where(query, [p], p.provider_connection_id == ^conn_id)
|
|
|
|
defp maybe_preload(query, nil), do: query
|
|
defp maybe_preload(query, preloads), do: preload(query, ^preloads)
|
|
end
|