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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-04-01 00:35:33 +01:00
parent 1004865013
commit 2fd7a0323e
3 changed files with 183 additions and 10 deletions

View File

@ -352,6 +352,29 @@ defmodule Berrypod.Products do
end) end)
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 """ @doc """
Recomputes denormalized fields from a product's variants. Recomputes denormalized fields from a product's variants.
Called after variant sync to keep cached fields up to date. Called after variant sync to keep cached fields up to date.

View File

@ -48,6 +48,15 @@ defmodule Berrypod.Redirects do
:ok :ok
end 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 defp invalidate_cache(from_path) do
:ets.delete(@table, from_path) :ets.delete(@table, from_path)
end end
@ -90,6 +99,10 @@ defmodule Berrypod.Redirects do
attrs = Map.put(attrs, :to_path, to_path) attrs = Map.put(attrs, :to_path, to_path)
from_path = attrs[:from_path] || attrs["from_path"] from_path = attrs[:from_path] || attrs["from_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 any existing redirects that point to our from_path
flatten_incoming(from_path, to_path) flatten_incoming(from_path, to_path)
@ -105,6 +118,7 @@ defmodule Berrypod.Redirects do
error error
end end
end end
end
@doc """ @doc """
Creates a manual redirect (from admin UI). Creates a manual redirect (from admin UI).

View File

@ -307,6 +307,142 @@ defmodule Berrypod.Settings do
end end
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 # Private helpers
defp fetch_setting(key) do defp fetch_setting(key) do