add URL redirects with ETS-cached plug, broken URL tracking, and admin UI
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>
This commit is contained in:
jamey
2026-02-26 14:14:14 +00:00
parent 23e95a3de6
commit 6e57af82fc
21 changed files with 1493 additions and 24 deletions

View File

@@ -118,6 +118,14 @@
<.icon name="hero-envelope" class="size-5" /> Email
</.link>
</li>
<li>
<.link
navigate={~p"/admin/redirects"}
class={admin_nav_active?(@current_path, "/admin/redirects")}
>
<.icon name="hero-arrow-uturn-right" class="size-5" /> Redirects
</.link>
</li>
</ul>
</nav>

View File

@@ -59,5 +59,6 @@ defmodule BerrypodWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug BerrypodWeb.Router
plug BerrypodWeb.Plugs.Redirects
plug BerrypodWeb.Plugs.BrokenUrlTracker, router: BerrypodWeb.Router
end

View File

@@ -0,0 +1,279 @@
defmodule BerrypodWeb.Admin.Redirects do
use BerrypodWeb, :live_view
alias Berrypod.Redirects
@valid_tabs ~w(redirects broken create)
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Redirects.subscribe()
socket =
socket
|> assign(:page_title, "Redirects")
|> assign(:redirects, Redirects.list_redirects())
|> assign(:broken_urls, Redirects.list_broken_urls())
|> assign(
:form,
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "redirects"
{:noreply, assign(socket, :tab, tab)}
end
@impl true
def handle_info({:redirects_changed, _action}, socket) do
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
end
def handle_info({:broken_urls_changed, _path}, socket) do
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/redirects?#{%{tab: tab}}")}
end
def handle_event("delete_redirect", %{"id" => id}, socket) do
redirect = Redirects.get_redirect!(id)
{:ok, _} = Redirects.delete_redirect(redirect)
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
end
def handle_event("create_redirect", %{"redirect" => params}, socket) do
from_path = params["from_path"]
case Redirects.create_manual(%{
from_path: from_path,
to_path: params["to_path"],
status_code: String.to_integer(params["status_code"])
}) do
{:ok, _redirect} ->
resolve_matching_broken_url(from_path)
socket =
socket
|> assign(
:form,
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
)
|> put_flash(:info, "Redirect created")
|> push_patch(to: ~p"/admin/redirects?#{%{tab: "redirects"}}")
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
def handle_event("ignore_broken_url", %{"id" => id}, socket) do
broken_url = Redirects.get_broken_url!(id)
{:ok, _} = Redirects.ignore_broken_url(broken_url)
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
end
def handle_event("redirect_broken_url", %{"path" => path}, socket) do
socket =
socket
|> assign(
:form,
to_form(%{"from_path" => path, "to_path" => "", "status_code" => "301"}, as: :redirect)
)
|> push_patch(to: ~p"/admin/redirects?#{%{tab: "create"}}")
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<.header>
Redirects
</.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<.tab_button tab="redirects" label="Active" count={length(@redirects)} active={@tab} />
<.tab_button tab="broken" label="Broken URLs" count={length(@broken_urls)} active={@tab} />
<.tab_button tab="create" label="Create" active={@tab} />
</div>
<%= if @tab == "redirects" do %>
<.redirects_table redirects={@redirects} />
<% end %>
<%= if @tab == "broken" do %>
<.broken_urls_table broken_urls={@broken_urls} />
<% end %>
<%= if @tab == "create" do %>
<.create_form form={@form} />
<% end %>
"""
end
defp redirects_table(assigns) do
~H"""
<%= if @redirects == [] do %>
<p>No redirects yet.</p>
<% else %>
<table class="admin-table">
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Source</th>
<th>Hits</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for redirect <- @redirects do %>
<tr>
<td><code>{redirect.from_path}</code></td>
<td><code>{redirect.to_path}</code></td>
<td>
<span class={"badge badge-#{source_colour(redirect.source)}"}>
{redirect.source}
</span>
</td>
<td>{redirect.hit_count}</td>
<td>{Calendar.strftime(redirect.inserted_at, "%d %b %Y")}</td>
<td>
<button
phx-click="delete_redirect"
phx-value-id={redirect.id}
data-confirm="Delete this redirect?"
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Delete
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
"""
end
defp broken_urls_table(assigns) do
~H"""
<%= if @broken_urls == [] do %>
<p>No broken URLs detected.</p>
<% else %>
<table class="admin-table">
<thead>
<tr>
<th>Path</th>
<th>Prior traffic</th>
<th>404s</th>
<th>First seen</th>
<th>Last seen</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for broken_url <- @broken_urls do %>
<tr>
<td><code>{broken_url.path}</code></td>
<td>{broken_url.prior_analytics_hits}</td>
<td>{broken_url.recent_404_count}</td>
<td>{Calendar.strftime(broken_url.first_seen_at, "%d %b %Y")}</td>
<td>{Calendar.strftime(broken_url.last_seen_at, "%d %b %Y")}</td>
<td class="flex gap-2">
<button
phx-click="redirect_broken_url"
phx-value-path={broken_url.path}
class="admin-btn admin-btn-sm admin-btn-primary"
>
Redirect
</button>
<button
phx-click="ignore_broken_url"
phx-value-id={broken_url.id}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Ignore
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
"""
end
defp create_form(assigns) do
~H"""
<.form for={@form} phx-submit="create_redirect" style="max-width: 32rem;">
<.input
field={@form[:from_path]}
label="From path"
placeholder="/products/old-slug"
required
/>
<.input
field={@form[:to_path]}
label="To path"
placeholder="/products/new-slug"
required
/>
<.input
field={@form[:status_code]}
type="select"
label="Status code"
options={[{"301 (permanent)", "301"}, {"302 (temporary)", "302"}]}
/>
<button type="submit" class="admin-btn admin-btn-primary">Create redirect</button>
</.form>
"""
end
defp tab_button(assigns) do
assigns =
assigns
|> assign(:active, assigns.active == assigns.tab)
|> assign_new(:count, fn -> nil end)
~H"""
<button
phx-click="switch_tab"
phx-value-tab={@tab}
class={[
"admin-btn admin-btn-sm",
@active && "admin-btn-primary",
!@active && "admin-btn-ghost"
]}
>
{@label}
<span :if={@count && @count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
</button>
"""
end
defp resolve_matching_broken_url(from_path) do
case Redirects.get_broken_url_by_path(from_path) do
nil -> :ok
broken_url -> Redirects.mark_broken_url_resolved(broken_url)
end
end
defp source_colour("auto_slug_change"), do: "info"
defp source_colour("auto_product_deleted"), do: "warning"
defp source_colour("analytics_detected"), do: "accent"
defp source_colour("admin"), do: "neutral"
defp source_colour(_), do: "neutral"
end

View File

@@ -0,0 +1,34 @@
defmodule BerrypodWeb.Plugs.BrokenUrlTracker do
@moduledoc """
Wraps the router to record 404s in the broken URLs table.
Works in dev mode too — Plug.Debugger intercepts exceptions before
error templates render, so we catch NoRouteError here, record it,
then re-raise so the normal error handling continues.
"""
@behaviour Plug
def init(opts) do
router = Keyword.fetch!(opts, :router)
router_opts = router.init([])
{router, router_opts}
end
def call(conn, {router, router_opts}) do
router.call(conn, router_opts)
rescue
e in Phoenix.Router.NoRouteError ->
unless static_path?(conn.request_path) do
Berrypod.Redirects.record_broken_url(conn.request_path, 0)
end
reraise e, __STACKTRACE__
end
defp static_path?(path) do
String.starts_with?(path, "/assets/") or
String.starts_with?(path, "/images/") or
String.starts_with?(path, "/favicon")
end
end

View File

@@ -0,0 +1,66 @@
defmodule BerrypodWeb.Plugs.Redirects do
@moduledoc """
Plug that handles URL normalisation and custom redirects.
Three concerns in one pass:
1. Trailing slash removal (/products/foo/ → /products/foo)
2. Case normalisation for shop paths (/Products/Foo → /products/foo)
3. Custom redirect lookup from the redirects table (ETS-cached)
All redirects preserve query params.
"""
import Plug.Conn
alias Berrypod.Redirects
# Only case-normalise paths under these prefixes (SEO-relevant shop routes).
# Paths with tokens, API keys, or other case-sensitive segments are excluded.
@lowercase_prefixes ~w(/products /collections /about /delivery /privacy /terms /search /cart /contact)
def init(opts), do: opts
def call(conn, _opts) do
path = conn.request_path
stripped = strip_trailing_slash(path)
cond do
# Trailing slash — redirect to canonical form
stripped != path ->
redirect_to(conn, stripped, 301)
# Case mismatch on a shop path — redirect to lowercase
lowercase_path?(path) and String.downcase(path) != path ->
redirect_to(conn, String.downcase(path), 301)
# Check redirect table (ETS-cached)
:else ->
case Redirects.lookup(path) do
{:ok, redirect} ->
Redirects.increment_hit_count(redirect)
redirect_to(conn, redirect.to_path, redirect.status_code)
:not_found ->
conn
end
end
end
defp lowercase_path?(path) do
Enum.any?(@lowercase_prefixes, &String.starts_with?(String.downcase(path), &1))
end
defp redirect_to(conn, target, status_code) do
location = append_query(target, conn.query_string)
conn
|> put_resp_header("location", location)
|> send_resp(status_code, "")
|> halt()
end
defp strip_trailing_slash("/"), do: "/"
defp strip_trailing_slash(path), do: String.trim_trailing(path, "/")
defp append_query(path, ""), do: path
defp append_query(path, qs), do: "#{path}?#{qs}"
end

View File

@@ -229,6 +229,7 @@ defmodule BerrypodWeb.Router do
live "/providers/:id/edit", Admin.Providers.Form, :edit
live "/settings", Admin.Settings, :index
live "/settings/email", Admin.EmailSettings, :index
live "/redirects", Admin.Redirects, :index
end
# Theme editor: admin root layout but full-screen (no sidebar)