berrypod/lib/berrypod/products/product.ex
jamey 6e57af82fc
All checks were successful
deploy / deploy (push) Successful in 3m30s
add URL redirects with ETS-cached plug, broken URL tracking, and admin UI
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>
2026-02-26 14:14:14 +00:00

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