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:
parent
1004865013
commit
2fd7a0323e
@ -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.
|
||||||
|
|||||||
@ -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,19 +99,24 @@ 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"]
|
||||||
|
|
||||||
# Flatten any existing redirects that point to our from_path
|
# Don't create self-redirects
|
||||||
flatten_incoming(from_path, to_path)
|
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
|
case Repo.insert(changeset, on_conflict: :nothing, conflict_target: :from_path) do
|
||||||
{:ok, redirect} ->
|
{:ok, redirect} ->
|
||||||
put_cache(redirect.from_path, redirect.to_path, redirect.status_code, redirect.id)
|
put_cache(redirect.from_path, redirect.to_path, redirect.status_code, redirect.id)
|
||||||
broadcast({:redirects_changed, :created})
|
broadcast({:redirects_changed, :created})
|
||||||
{:ok, redirect}
|
{:ok, redirect}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
error
|
error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user