From 2fd7a0323ecf7264b827738cf39524d3d5ca87d4 Mon Sep 17 00:00:00 2001 From: jamey Date: Wed, 1 Apr 2026 00:35:33 +0100 Subject: [PATCH] add url prefix settings with validation and redirects Customisable route prefixes for products, collections, orders: - get_url_prefixes/0 and get_url_prefix/1 for retrieval - update_url_prefix/2 with format validation and conflict checks - Automatic redirects from old to new prefix URLs - list_product_slugs/0 and list_category_slugs/0 helpers - Redirects.clear_cache/0 for test isolation Co-Authored-By: Claude Opus 4.5 --- lib/berrypod/products.ex | 23 +++++++ lib/berrypod/redirects.ex | 34 +++++++--- lib/berrypod/settings.ex | 136 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 10 deletions(-) diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex index 2497094..501b57c 100644 --- a/lib/berrypod/products.ex +++ b/lib/berrypod/products.ex @@ -352,6 +352,29 @@ defmodule Berrypod.Products do end) end + @doc """ + Returns a list of all product slugs. + + Used for URL prefix redirects when admin changes the products prefix. + """ + def list_product_slugs do + from(p in Product, + where: p.visible == true and p.status == "active", + select: p.slug + ) + |> Repo.all() + end + + @doc """ + Returns a list of all category slugs. + + Used for URL prefix redirects when admin changes the collections prefix. + """ + def list_category_slugs do + list_categories() + |> Enum.map(& &1.slug) + end + @doc """ Recomputes denormalized fields from a product's variants. Called after variant sync to keep cached fields up to date. diff --git a/lib/berrypod/redirects.ex b/lib/berrypod/redirects.ex index 47eb716..e31c0fe 100644 --- a/lib/berrypod/redirects.ex +++ b/lib/berrypod/redirects.ex @@ -48,6 +48,15 @@ defmodule Berrypod.Redirects do :ok end + @doc "Clears the redirect cache without re-warming. Used in tests." + def clear_cache do + if :ets.whereis(@table) != :undefined do + :ets.delete_all_objects(@table) + end + + :ok + end + defp invalidate_cache(from_path) do :ets.delete(@table, from_path) end @@ -90,19 +99,24 @@ defmodule Berrypod.Redirects do attrs = Map.put(attrs, :to_path, to_path) from_path = attrs[:from_path] || attrs["from_path"] - # Flatten any existing redirects that point to our from_path - flatten_incoming(from_path, to_path) + # Don't create self-redirects + if from_path == to_path do + {:ok, :skipped} + else + # Flatten any existing redirects that point to our from_path + flatten_incoming(from_path, to_path) - changeset = Redirect.changeset(%Redirect{}, attrs) + changeset = Redirect.changeset(%Redirect{}, attrs) - case Repo.insert(changeset, on_conflict: :nothing, conflict_target: :from_path) do - {:ok, redirect} -> - put_cache(redirect.from_path, redirect.to_path, redirect.status_code, redirect.id) - broadcast({:redirects_changed, :created}) - {:ok, redirect} + case Repo.insert(changeset, on_conflict: :nothing, conflict_target: :from_path) do + {:ok, redirect} -> + put_cache(redirect.from_path, redirect.to_path, redirect.status_code, redirect.id) + broadcast({:redirects_changed, :created}) + {:ok, redirect} - error -> - error + error -> + error + end end end diff --git a/lib/berrypod/settings.ex b/lib/berrypod/settings.ex index ec61f06..c3b344f 100644 --- a/lib/berrypod/settings.ex +++ b/lib/berrypod/settings.ex @@ -307,6 +307,142 @@ defmodule Berrypod.Settings do end end + # ── URL prefixes ───────────────────────────────────────────── + + @valid_prefix_types ~w(products collections orders)a + @default_prefixes %{products: "products", collections: "collections", orders: "orders"} + + @doc """ + Gets the current URL prefixes map. + + Returns a map like `%{"products" => "p", "collections" => "c"}`. + Missing keys use defaults. + """ + def get_url_prefixes do + get_setting("url_prefixes") || %{} + end + + @doc """ + Gets the current prefix for a specific type. + + ## Examples + + iex> get_url_prefix(:products) + "p" # if customised, or "products" by default + """ + def get_url_prefix(type) when type in @valid_prefix_types do + prefixes = get_url_prefixes() + Map.get(prefixes, Atom.to_string(type)) || @default_prefixes[type] + end + + @doc """ + Updates a single URL prefix. + + Creates redirects from old prefix URLs to new ones if products/collections exist. + Invalidates the R module cache. + + ## Examples + + iex> update_url_prefix(:products, "p") + {:ok, %{"products" => "p"}} + """ + def update_url_prefix(type, new_prefix) + when type in @valid_prefix_types and is_binary(new_prefix) do + new_prefix = String.downcase(String.trim(new_prefix)) + + # Validate prefix format + cond do + new_prefix == "" -> + {:error, :empty_prefix} + + not Regex.match?(~r/^[a-z0-9-]+$/, new_prefix) -> + {:error, :invalid_format} + + reserved_prefix?(new_prefix) -> + {:error, :reserved_prefix} + + true -> + old_prefix = get_url_prefix(type) + prefixes = get_url_prefixes() + + # If same as current, no-op + if old_prefix == new_prefix do + {:ok, prefixes} + else + # Update the prefixes map + # If new prefix matches default, remove the key (use default) + new_prefixes = + if new_prefix == @default_prefixes[type] do + Map.delete(prefixes, Atom.to_string(type)) + else + Map.put(prefixes, Atom.to_string(type), new_prefix) + end + + # Save to database + {:ok, _} = put_setting("url_prefixes", new_prefixes, "json") + + # Create redirects for existing items + create_prefix_redirects(type, old_prefix, new_prefix) + + # Invalidate R module cache + BerrypodWeb.R.invalidate_all_sync() + + {:ok, new_prefixes} + end + end + end + + @doc """ + Resets a URL prefix to its default value. + """ + def reset_url_prefix(type) when type in @valid_prefix_types do + update_url_prefix(type, @default_prefixes[type]) + end + + # Check if prefix conflicts with reserved routes + defp reserved_prefix?(prefix) do + reserved = ~w(admin api auth checkout coming-soon health live phoenix static _) + prefix in reserved + end + + # Create redirects from old prefix to new prefix for existing items + defp create_prefix_redirects(:products, old_prefix, new_prefix) do + Berrypod.Products.list_product_slugs() + |> Enum.each(fn slug -> + Berrypod.Redirects.create_auto(%{ + from_path: "/#{old_prefix}/#{slug}", + to_path: "/#{new_prefix}/#{slug}", + source: "auto_prefix_change" + }) + end) + end + + defp create_prefix_redirects(:collections, old_prefix, new_prefix) do + Berrypod.Products.list_category_slugs() + |> Enum.each(fn slug -> + Berrypod.Redirects.create_auto(%{ + from_path: "/#{old_prefix}/#{slug}", + to_path: "/#{new_prefix}/#{slug}", + source: "auto_prefix_change" + }) + end) + + # Also redirect "all" and "sale" virtual collections + for slug <- ["all", "sale"] do + Berrypod.Redirects.create_auto(%{ + from_path: "/#{old_prefix}/#{slug}", + to_path: "/#{new_prefix}/#{slug}", + source: "auto_prefix_change" + }) + end + end + + defp create_prefix_redirects(:orders, _old_prefix, _new_prefix) do + # Orders use order numbers, which we can't enumerate easily + # The DynamicRoutes plug handles the redirect at runtime + :ok + end + # Private helpers defp fetch_setting(key) do