berrypod/lib/berrypod_web/r.ex

323 lines
9.9 KiB
Elixir
Raw Normal View History

defmodule BerrypodWeb.R do
@moduledoc """
Runtime URL paths with ETS caching. Short name for ergonomic use.
This module provides functions for generating URLs that respect custom URL slugs
and route prefixes configured by the shop owner.
## Usage
R.cart() # => "/basket" (if cart is renamed to basket)
R.product(123) # => "/p/123" (if products prefix is "p")
R.url(R.product(123)) # => "https://shop.com/p/123"
## Page slugs vs route prefixes
- **Page slugs** are single-segment paths like `/cart`, `/about`, `/contact`
- **Route prefixes** are multi-segment paths like `/products/:id`, `/collections/:slug`
Page slugs are stored on individual Page records. Route prefixes are stored in Settings.
## Routing
The router uses three catch-all routes (`/`, `/:prefix/:id_or_slug`, `/:slug`) that
delegate to `Shop.Page`, which resolves the actual page type at runtime using
`page_type_from_slug/1` and `prefix_type_from_segment/1`. See the router's
"Dynamic Shop Routes" comment block for the full list of handled pages.
To add a new system page type:
1. Add a clause to `default_page_type/1` and `default_slug/1`
2. Add the page module to `@page_modules` in `Shop.Page`
"""
use GenServer
alias Berrypod.{Pages, Settings}
@table __MODULE__
# =============================================================================
# Client API - Path functions
# =============================================================================
@doc "Returns the home page path (always /)."
def home, do: "/"
@doc "Returns the cart page path."
def cart, do: "/" <> page_slug(:cart)
@doc "Returns the about page path."
def about, do: "/" <> page_slug(:about)
@doc "Returns the contact page path."
def contact, do: "/" <> page_slug(:contact)
@doc "Returns the search page path."
def search, do: "/" <> page_slug(:search)
@doc "Returns the delivery page path."
def delivery, do: "/" <> page_slug(:delivery)
@doc "Returns the privacy page path."
def privacy, do: "/" <> page_slug(:privacy)
@doc "Returns the terms page path."
def terms, do: "/" <> page_slug(:terms)
@doc "Returns the checkout success page path."
def checkout_success, do: "/" <> page_slug(:checkout_success)
@doc "Returns the orders page path."
def orders, do: "/" <> page_slug(:orders)
@doc """
Returns the path for any page by slug.
This is a generic version that works with any page slug, including custom pages.
Prefer the specific functions (cart/0, about/0, etc.) when the page is known.
"""
def page("home"), do: "/"
def page("cart"), do: cart()
def page("about"), do: about()
def page("contact"), do: contact()
def page("search"), do: search()
def page("delivery"), do: delivery()
def page("privacy"), do: privacy()
def page("terms"), do: terms()
def page("checkout_success"), do: checkout_success()
def page("orders"), do: orders()
def page(slug) when is_binary(slug), do: "/" <> slug
@doc "Returns the path for a specific product."
def product(id), do: "/" <> prefix(:products) <> "/" <> to_string(id)
@doc "Returns the path for a specific collection."
def collection(slug), do: "/" <> prefix(:collections) <> "/" <> slug
@doc "Returns the path for a specific order."
def order(order_number), do: "/" <> prefix(:orders) <> "/" <> order_number
@doc "Returns a full URL (with scheme and host) for the given path."
def url(path), do: BerrypodWeb.Endpoint.url() <> path
# =============================================================================
# Reverse lookups (for router)
# =============================================================================
@doc """
Given a URL slug, returns the page type it maps to.
Returns:
- `{:page, :cart}` for system pages
- `{:custom, page}` for custom pages
- `nil` if not found
"""
def page_type_from_slug(slug) do
# First check if it's a custom URL slug for a system page
case lookup({:reverse_slug, slug}) do
{:ok, page_type} ->
{:page, page_type}
:not_found ->
# Check if it's a default slug for a system page
case default_page_type(slug) do
nil ->
# Check if it's a custom page
case Pages.get_published_page_by_effective_url(slug) do
nil -> nil
page -> {:custom, page}
end
page_type ->
{:page, page_type}
end
end
end
@doc """
Given a URL segment, returns the prefix type it maps to.
Returns `:products`, `:collections`, `:orders`, or `nil`.
"""
def prefix_type_from_segment(segment) do
case lookup({:reverse_prefix, segment}) do
{:ok, type} -> type
:not_found -> default_prefix_type(segment)
end
end
@doc """
Returns the current prefix for a given type.
Used by the DynamicRoutes plug to check if a custom prefix is set.
## Examples
R.prefix(:products) # => "p" (if customised) or "products" (default)
R.prefix(:collections) # => "c" (if customised) or "collections" (default)
"""
def prefix(prefix_type) do
key = {:prefix, prefix_type}
case lookup(key) do
{:ok, prefix} -> prefix
:not_found -> default_prefix(prefix_type)
end
end
# =============================================================================
# Cache management
# =============================================================================
@doc "Invalidates a specific cache key."
def invalidate(key) do
GenServer.cast(__MODULE__, {:invalidate, key})
end
@doc "Invalidates all cache entries (use after prefix changes)."
def invalidate_all do
GenServer.cast(__MODULE__, :invalidate_all)
end
@doc "Invalidates all cache entries synchronously. Use when you need the cache refreshed before proceeding."
def invalidate_all_sync do
GenServer.call(__MODULE__, :invalidate_all)
end
@doc "Clears all cache entries without re-warming. Used in tests to reset to defaults."
def clear do
GenServer.call(__MODULE__, :clear)
end
@doc "Warms the cache with current values from the database."
def warm_cache do
GenServer.cast(__MODULE__, :warm_cache)
end
# =============================================================================
# GenServer
# =============================================================================
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@impl GenServer
def init(_) do
:ets.new(@table, [:named_table, :public, read_concurrency: true])
do_warm_cache()
{:ok, %{}}
end
@impl GenServer
def handle_cast({:invalidate, key}, state) do
:ets.delete(@table, key)
{:noreply, state}
end
def handle_cast(:invalidate_all, state) do
:ets.delete_all_objects(@table)
do_warm_cache()
{:noreply, state}
end
def handle_cast(:warm_cache, state) do
do_warm_cache()
{:noreply, state}
end
@impl GenServer
def handle_call(:invalidate_all, _from, state) do
:ets.delete_all_objects(@table)
do_warm_cache()
{:reply, :ok, state}
end
def handle_call(:clear, _from, state) do
:ets.delete_all_objects(@table)
{:reply, :ok, state}
end
# =============================================================================
# Private - Cache operations
# =============================================================================
defp page_slug(page_type) do
key = {:slug, page_type}
case lookup(key) do
{:ok, slug} -> slug
:not_found -> default_slug(page_type)
end
end
defp lookup(key) do
case :ets.lookup(@table, key) do
[{^key, value}] -> {:ok, value}
[] -> :not_found
end
rescue
ArgumentError -> :not_found
end
defp do_warm_cache do
# Load page slugs
Pages.list_pages_with_custom_urls()
|> Enum.each(fn page ->
page_type = String.to_existing_atom(page.slug)
:ets.insert(@table, {{:slug, page_type}, page.url_slug})
:ets.insert(@table, {{:reverse_slug, page.url_slug}, page_type})
end)
# Load route prefixes from settings
prefixes = Settings.get_setting("url_prefixes") || %{}
Enum.each(prefixes, fn {type_str, prefix} ->
type = String.to_existing_atom(type_str)
:ets.insert(@table, {{:prefix, type}, prefix})
:ets.insert(@table, {{:reverse_prefix, prefix}, type})
end)
rescue
# During startup, tables/atoms might not exist yet
_ -> :ok
end
# =============================================================================
# Defaults
# =============================================================================
defp default_slug(:cart), do: "cart"
defp default_slug(:about), do: "about"
defp default_slug(:contact), do: "contact"
defp default_slug(:search), do: "search"
defp default_slug(:delivery), do: "delivery"
defp default_slug(:privacy), do: "privacy"
defp default_slug(:terms), do: "terms"
defp default_slug(:checkout_success), do: "checkout/success"
defp default_slug(:orders), do: "orders"
defp default_slug(_), do: nil
defp default_prefix(:products), do: "products"
defp default_prefix(:collections), do: "collections"
defp default_prefix(:orders), do: "orders"
defp default_prefix(_), do: nil
# Map default slugs back to page types (for router)
defp default_page_type("cart"), do: :cart
defp default_page_type("about"), do: :about
defp default_page_type("contact"), do: :contact
defp default_page_type("search"), do: :search
defp default_page_type("delivery"), do: :delivery
defp default_page_type("privacy"), do: :privacy
defp default_page_type("terms"), do: :terms
defp default_page_type("orders"), do: :orders
defp default_page_type("checkout/success"), do: :checkout_success
defp default_page_type(_), do: nil
# Map default prefixes back to types
defp default_prefix_type("products"), do: :products
defp default_prefix_type("collections"), do: :collections
defp default_prefix_type("orders"), do: :orders
defp default_prefix_type(_), do: nil
end