All checks were successful
deploy / deploy (push) Successful in 3m30s
Redirects context with redirect/broken_url schemas, chain flattening, ETS cache for fast lookups in the request pipeline. BrokenUrlTracker plug logs 404s. Auto-redirect on product slug change via upsert_product hook. Admin redirects page with active/broken tabs, manual create form. RedirectPrunerWorker cleans up old broken URLs. 1227 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
5.6 KiB
Elixir
204 lines
5.6 KiB
Elixir
defmodule Berrypod.Products.Product do
|
|
@moduledoc """
|
|
Schema for products synced from POD providers.
|
|
|
|
Products are uniquely identified by the combination of
|
|
provider_connection_id and provider_product_id.
|
|
"""
|
|
|
|
use Ecto.Schema
|
|
import Ecto.Changeset
|
|
|
|
@primary_key {:id, :binary_id, autogenerate: true}
|
|
@foreign_key_type :binary_id
|
|
|
|
@statuses ~w(active draft archived)
|
|
|
|
schema "products" do
|
|
field :provider_product_id, :string
|
|
field :title, :string
|
|
field :description, :string
|
|
field :slug, :string
|
|
field :status, :string, default: "active"
|
|
field :visible, :boolean, default: true
|
|
field :category, :string
|
|
field :provider_data, :map, default: %{}
|
|
field :checksum, :string
|
|
|
|
# Denormalized from variants — recomputed by Products.recompute_cached_fields/1
|
|
field :cheapest_price, :integer, default: 0
|
|
field :compare_at_price, :integer
|
|
field :in_stock, :boolean, default: true
|
|
field :on_sale, :boolean, default: false
|
|
|
|
belongs_to :provider_connection, Berrypod.Products.ProviderConnection
|
|
has_many :images, Berrypod.Products.ProductImage
|
|
has_many :variants, Berrypod.Products.ProductVariant
|
|
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
@doc """
|
|
Returns the list of valid product statuses.
|
|
"""
|
|
def statuses, do: @statuses
|
|
|
|
@doc """
|
|
Changeset for creating or updating a product.
|
|
"""
|
|
def changeset(product, attrs) do
|
|
product
|
|
|> cast(attrs, [
|
|
:provider_connection_id,
|
|
:provider_product_id,
|
|
:title,
|
|
:description,
|
|
:slug,
|
|
:status,
|
|
:visible,
|
|
:category,
|
|
:provider_data,
|
|
:checksum
|
|
])
|
|
|> generate_slug_if_missing()
|
|
|> validate_required([:provider_connection_id, :provider_product_id, :title, :slug])
|
|
|> validate_inclusion(:status, @statuses)
|
|
|> unique_constraint(:slug)
|
|
|> unique_constraint([:provider_connection_id, :provider_product_id])
|
|
end
|
|
|
|
@doc """
|
|
Changeset for admin storefront controls (visibility and category only).
|
|
"""
|
|
def storefront_changeset(product, attrs) do
|
|
product
|
|
|> cast(attrs, [:visible, :category])
|
|
end
|
|
|
|
@doc """
|
|
Changeset for recomputing denormalized fields from variants.
|
|
"""
|
|
def recompute_changeset(product, attrs) do
|
|
product
|
|
|> cast(attrs, [:cheapest_price, :compare_at_price, :in_stock, :on_sale])
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Display helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@doc """
|
|
Returns the primary (first by position) image, or nil.
|
|
Works with preloaded images association or plain maps.
|
|
"""
|
|
def primary_image(%{images: images}) when is_list(images) do
|
|
Enum.min_by(images, & &1.position, fn -> nil end)
|
|
end
|
|
|
|
def primary_image(_), do: nil
|
|
|
|
@doc """
|
|
Returns the second image by position (hover image), or nil.
|
|
"""
|
|
def hover_image(%{images: images}) when is_list(images) and length(images) >= 2 do
|
|
images
|
|
|> Enum.sort_by(& &1.position)
|
|
|> Enum.at(1)
|
|
end
|
|
|
|
def hover_image(_), do: nil
|
|
|
|
@doc """
|
|
Extracts option types from provider_data.
|
|
Returns a list of %{name: "Size", type: :size, values: [%{title: "S"}, ...]}.
|
|
Color options include :hex from the provider's color data.
|
|
"""
|
|
def option_types(%{provider_data: %{"options" => options}}) when is_list(options) do
|
|
Enum.map(options, fn opt ->
|
|
type = option_type_atom(opt["type"])
|
|
|
|
values =
|
|
Enum.map(opt["values"] || [], fn val ->
|
|
base = %{title: val["title"]}
|
|
|
|
case val["colors"] do
|
|
[hex | _] -> Map.put(base, :hex, hex)
|
|
_ -> base
|
|
end
|
|
end)
|
|
|
|
%{name: singularize_option_name(opt["name"]), type: type, values: values}
|
|
end)
|
|
end
|
|
|
|
def option_types(%{option_types: option_types}) when is_list(option_types), do: option_types
|
|
def option_types(_), do: []
|
|
|
|
defp option_type_atom("color"), do: :color
|
|
defp option_type_atom(_), do: :size
|
|
|
|
# Printify sends plural names ("Colors", "Sizes") but variant options
|
|
# use singular — keep them consistent so gallery filtering works.
|
|
defp singularize_option_name("Colors"), do: "Color"
|
|
defp singularize_option_name("Sizes"), do: "Size"
|
|
defp singularize_option_name(name), do: name
|
|
|
|
@doc """
|
|
Generates a checksum from provider data for detecting changes.
|
|
"""
|
|
def compute_checksum(provider_data) when is_map(provider_data) do
|
|
provider_data
|
|
|> Jason.encode!()
|
|
|> then(&:crypto.hash(:sha256, &1))
|
|
|> Base.encode16(case: :lower)
|
|
|> binary_part(0, 16)
|
|
end
|
|
|
|
def compute_checksum(_), do: nil
|
|
|
|
defp generate_slug_if_missing(changeset) do
|
|
slug_change = get_change(changeset, :slug)
|
|
title_change = get_change(changeset, :title)
|
|
current_slug = get_field(changeset, :slug)
|
|
|
|
cond do
|
|
# Explicit slug provided — use it as-is
|
|
slug_change != nil ->
|
|
changeset
|
|
|
|
# Title changed — regenerate slug to match
|
|
title_change != nil ->
|
|
put_change(changeset, :slug, Slug.slugify(title_change))
|
|
|
|
# No slug yet — generate from title
|
|
current_slug == nil ->
|
|
title = get_field(changeset, :title)
|
|
|
|
if title do
|
|
put_change(changeset, :slug, Slug.slugify(title))
|
|
else
|
|
changeset
|
|
end
|
|
|
|
# Slug exists and title didn't change — keep it
|
|
true ->
|
|
changeset
|
|
end
|
|
end
|
|
end
|
|
|
|
defmodule Slug do
|
|
@moduledoc false
|
|
|
|
def slugify(nil), do: nil
|
|
|
|
def slugify(string) when is_binary(string) do
|
|
string
|
|
|> String.downcase()
|
|
|> String.replace(~r/[^\w\s-]/, "")
|
|
|> String.replace(~r/\s+/, "-")
|
|
|> String.replace(~r/-+/, "-")
|
|
|> String.trim("-")
|
|
end
|
|
end
|