berrypod/lib/berrypod/products.ex
jamey 0853b6f528 share provider connection logic between setup wizard and providers form
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>
2026-03-03 15:19:17 +00:00

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