All checks were successful
deploy / deploy (push) Successful in 1m26s
Every shop page load was triggering ~18 DB queries for data that rarely changes (theme settings, nav items, categories, shipping countries, logo, header image). On a shared-cpu-1x Fly machine with SQLite this was the primary performance bottleneck. - Add SettingsCache GenServer+ETS for all non-encrypted settings - Cache list_categories() with single-query N+1 fix (correlated subquery) - Cache list_available_countries_with_names() in shipping - Cache Media.get_logo() and Media.get_header() - Remove duplicate LoadTheme plug from :shop and :admin pipelines - Invalidate caches on writes (put_setting, product sync, media upload) - Clear caches between tests via DataCase/ConnCase setup Per-page queries reduced from ~18 to ~2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
938 lines
27 KiB
Elixir
938 lines
27 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 """
|
|
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
|
|
# =============================================================================
|
|
|
|
# Listing pages only need images (price/stock are denormalized on product)
|
|
@listing_preloads [images: :image]
|
|
# Detail page also needs variants for the variant selector
|
|
@detail_preloads [images: :image, variants: []]
|
|
|
|
@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")
|
|
|> preload(^@detail_preloads)
|
|
|> Repo.one()
|
|
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])
|
|
|> preload(^@listing_preloads)
|
|
|> Repo.all()
|
|
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
|
|
Product
|
|
|> where([p], p.visible == true and p.status == "active")
|
|
|> apply_visible_filters(opts)
|
|
|> apply_sort(opts[:sort])
|
|
|> maybe_exclude(opts[:exclude])
|
|
|> preload(^@listing_preloads)
|
|
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
|
|
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, 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, 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 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]]
|
|
)
|
|
|> 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
|