diff --git a/lib/berrypod/analytics.ex b/lib/berrypod/analytics.ex index 240450a..ec8ad50 100644 --- a/lib/berrypod/analytics.ex +++ b/lib/berrypod/analytics.ex @@ -364,6 +364,20 @@ defmodule Berrypod.Analytics do |> Repo.all() end + @doc """ + Counts all-time pageviews for a specific path. + + Used by the broken URL tracker to distinguish real broken URLs + (paths that previously had traffic) from bot noise. + """ + def count_pageviews_for_path(path) do + from(e in Event, + where: e.name == "pageview" and e.pathname == ^path, + select: count() + ) + |> Repo.one() + end + @doc """ Deletes events older than the given datetime. Used by the retention worker. """ diff --git a/lib/berrypod/redirects.ex b/lib/berrypod/redirects.ex index 44d4ae3..38bfa1aa 100644 --- a/lib/berrypod/redirects.ex +++ b/lib/berrypod/redirects.ex @@ -336,6 +336,44 @@ defmodule Berrypod.Redirects do Repo.one(from b in BrokenUrl, where: b.path == ^path and b.status == "pending") end + # ── Auto-resolve ── + + @doc """ + Attempts to auto-resolve a broken product URL using FTS5 search. + + Converts the slug back to words and searches the product index. + If exactly one result is found, creates a redirect and marks + the broken URL as resolved. Multiple or zero results are left + for admin review. + """ + def attempt_auto_resolve("/products/" <> old_slug = path) do + query = old_slug |> String.replace("-", " ") |> String.trim() + + case Berrypod.Search.search(query) do + [%{slug: new_slug}] when new_slug != old_slug -> + case create_auto(%{ + from_path: path, + to_path: "/products/#{new_slug}", + source: "analytics_auto_resolved" + }) do + {:ok, _redirect} -> + if broken = get_broken_url_by_path(path) do + mark_broken_url_resolved(broken) + end + + :resolved + + _error -> + :no_match + end + + _ -> + :no_match + end + end + + def attempt_auto_resolve(_path), do: :no_match + # ── Pruning ── @doc """ diff --git a/lib/berrypod/redirects/redirect.ex b/lib/berrypod/redirects/redirect.ex index 56939dc..d0e6ac3 100644 --- a/lib/berrypod/redirects/redirect.ex +++ b/lib/berrypod/redirects/redirect.ex @@ -5,7 +5,7 @@ defmodule Berrypod.Redirects.Redirect do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @sources ~w(auto_slug_change auto_product_deleted analytics_detected admin) + @sources ~w(auto_slug_change auto_product_deleted analytics_auto_resolved admin) schema "redirects" do field :from_path, :string diff --git a/lib/berrypod_web/plugs/broken_url_tracker.ex b/lib/berrypod_web/plugs/broken_url_tracker.ex index 4ab9ac4..978015c 100644 --- a/lib/berrypod_web/plugs/broken_url_tracker.ex +++ b/lib/berrypod_web/plugs/broken_url_tracker.ex @@ -20,7 +20,12 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTracker do rescue e in Phoenix.Router.NoRouteError -> unless static_path?(conn.request_path) do - Berrypod.Redirects.record_broken_url(conn.request_path, 0) + prior_hits = Berrypod.Analytics.count_pageviews_for_path(conn.request_path) + Berrypod.Redirects.record_broken_url(conn.request_path, prior_hits) + + if prior_hits > 0 do + Berrypod.Redirects.attempt_auto_resolve(conn.request_path) + end end reraise e, __STACKTRACE__ diff --git a/test/berrypod/analytics_test.exs b/test/berrypod/analytics_test.exs index 8243e2a..e94b9e6 100644 --- a/test/berrypod/analytics_test.exs +++ b/test/berrypod/analytics_test.exs @@ -366,6 +366,23 @@ defmodule Berrypod.AnalyticsTest do end end + describe "count_pageviews_for_path/1" do + test "counts all pageviews for a specific path" do + v1 = :crypto.strong_rand_bytes(8) + v2 = :crypto.strong_rand_bytes(8) + + insert_event(%{visitor_hash: v1, pathname: "/products/classic-tee"}) + insert_event(%{visitor_hash: v2, pathname: "/products/classic-tee"}) + insert_event(%{visitor_hash: v1, pathname: "/products/other"}) + + assert Analytics.count_pageviews_for_path("/products/classic-tee") == 2 + end + + test "returns 0 for paths with no history" do + assert Analytics.count_pageviews_for_path("/products/never-visited") == 0 + end + end + describe "delete_events_before/1" do test "deletes old events" do old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second) diff --git a/test/berrypod/redirects_integration_test.exs b/test/berrypod/redirects_integration_test.exs index baf6f2d..76abe61 100644 --- a/test/berrypod/redirects_integration_test.exs +++ b/test/berrypod/redirects_integration_test.exs @@ -1,7 +1,8 @@ defmodule Berrypod.RedirectsIntegrationTest do - use Berrypod.DataCase, async: true + # async: false because FTS5 search index is shared state + use Berrypod.DataCase, async: false - alias Berrypod.{Products, Redirects} + alias Berrypod.{Products, Redirects, Search} import Berrypod.ProductsFixtures setup do @@ -88,4 +89,45 @@ defmodule Berrypod.RedirectsIntegrationTest do Redirects.lookup("/products/#{product.slug}") end end + + describe "attempt_auto_resolve/1" do + test "creates redirect when FTS5 finds a single match" do + # Product was renamed: "Classic Tee" -> "Classic Tee V2" + # Old slug was "classic-tee", new slug is "classic-tee-v2" + product = + product_fixture(%{ + title: "Classic Tee V2", + slug: "classic-tee-v2", + category: "T-Shirts", + visible: true, + status: "active" + }) + + product_variant_fixture(%{product: product, title: "Medium / Black", price: 2500}) + Search.rebuild_index() + + # Record a broken URL for the old slug + {:ok, _} = Redirects.record_broken_url("/products/classic-tee", 10) + + assert :resolved = Redirects.attempt_auto_resolve("/products/classic-tee") + + # Redirect should point to the matched product + assert {:ok, %{to_path: "/products/classic-tee-v2"}} = + Redirects.lookup("/products/classic-tee") + + # Broken URL should be marked resolved + assert Redirects.list_broken_urls("pending") == [] + end + + test "returns :no_match when no search results" do + Search.rebuild_index() + + assert :no_match = Redirects.attempt_auto_resolve("/products/totally-unique-nonexistent") + end + + test "returns :no_match for non-product paths" do + assert :no_match = Redirects.attempt_auto_resolve("/about") + assert :no_match = Redirects.attempt_auto_resolve("/collections/old-category") + end + end end