All checks were successful
deploy / deploy (push) Successful in 4m59s
- Per-page SEO controls: meta robots directives, focus keyword, OG image - Site-wide default OG image in admin settings - FAQ block type with FAQPage JSON-LD schema - Enhanced Organization JSON-LD with business info, contact, address - Image sitemap with product images - SEO preview panel with Google/social card mockups - SEO checklist with real-time scoring - Business info section in site editor - GSC integration scaffolding (OAuth, client, cache) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
388 lines
10 KiB
Elixir
388 lines
10 KiB
Elixir
defmodule Berrypod.Pages do
|
|
@moduledoc """
|
|
Context for database-driven page definitions.
|
|
|
|
Every page is a flat list of blocks stored as JSON. This module provides
|
|
the public API for reading, writing, and loading block data for pages.
|
|
|
|
Lookup order: ETS cache -> DB -> defaults (system pages only).
|
|
Custom pages return nil when not in DB.
|
|
"""
|
|
|
|
alias Berrypod.Repo
|
|
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
|
|
|
|
import Ecto.Query
|
|
|
|
# ── Read ──────────────────────────────────────────────────────────
|
|
|
|
@doc """
|
|
Gets a page definition by slug.
|
|
|
|
Checks the ETS cache first, falls back to DB, then to built-in defaults
|
|
(for system pages only). Returns a map with page fields, or nil for
|
|
unknown custom slugs.
|
|
"""
|
|
def get_page(slug) do
|
|
case PageCache.get(slug) do
|
|
{:ok, page_data} -> page_data
|
|
:miss -> get_page_uncached(slug)
|
|
end
|
|
rescue
|
|
# ETS table might not exist yet during startup
|
|
ArgumentError -> get_page_uncached(slug)
|
|
end
|
|
|
|
@doc """
|
|
Gets a page from the DB. System slugs fall back to defaults; custom slugs
|
|
return nil when not found. Used by PageCache.warm/0.
|
|
"""
|
|
def get_page_from_db(slug) do
|
|
case Repo.one(from p in Page, where: p.slug == ^slug) do
|
|
%Page{} = page ->
|
|
page_to_map(page)
|
|
|
|
nil ->
|
|
if Page.system_slug?(slug) do
|
|
Defaults.for_slug(slug)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc "Gets the raw Page struct by slug. Returns nil if not in DB."
|
|
def get_page_struct(slug) do
|
|
Repo.one(from p in Page, where: p.slug == ^slug)
|
|
end
|
|
|
|
defp get_page_uncached(slug) do
|
|
page_data = get_page_from_db(slug)
|
|
|
|
# Cache for next time (best-effort, ETS might not be up yet)
|
|
try do
|
|
PageCache.put(slug, page_data)
|
|
rescue
|
|
ArgumentError -> :ok
|
|
end
|
|
|
|
page_data
|
|
end
|
|
|
|
@doc "Lists all 14 system pages with their current definitions."
|
|
def list_system_pages do
|
|
Page.system_slugs()
|
|
|> Enum.map(&get_page/1)
|
|
end
|
|
|
|
@doc false
|
|
def list_pages, do: list_system_pages()
|
|
|
|
@doc "Lists all custom pages, ordered by title."
|
|
def list_custom_pages do
|
|
from(p in Page, where: p.type == "custom", order_by: [asc: p.title])
|
|
|> Repo.all()
|
|
|> Enum.map(&page_to_map/1)
|
|
end
|
|
|
|
@doc "Lists all pages (system + custom)."
|
|
def list_all_pages do
|
|
list_system_pages() ++ list_custom_pages()
|
|
end
|
|
|
|
@doc "Lists pages that have a custom URL slug set. Used by Routes cache."
|
|
def list_pages_with_custom_urls do
|
|
from(p in Page, where: not is_nil(p.url_slug))
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc "Gets a published page by its effective URL (url_slug or slug)."
|
|
def get_published_page_by_effective_url(url) do
|
|
# First check for custom url_slug match
|
|
case Repo.one(from p in Page, where: p.url_slug == ^url and p.published == true) do
|
|
nil ->
|
|
# Fall back to slug match for custom pages
|
|
Repo.one(
|
|
from p in Page,
|
|
where: p.slug == ^url and p.type == "custom" and p.published == true
|
|
)
|
|
|
|
page ->
|
|
page
|
|
end
|
|
end
|
|
|
|
# ── Write ─────────────────────────────────────────────────────────
|
|
|
|
@doc """
|
|
Saves a page's block list. Creates or updates the DB row and invalidates
|
|
the cache. Uses the appropriate changeset based on page type.
|
|
|
|
Returns `{:ok, page}` or `{:error, changeset}`.
|
|
"""
|
|
def save_page(slug, attrs) do
|
|
existing = Repo.one(from p in Page, where: p.slug == ^slug)
|
|
|
|
changeset_fn =
|
|
if Page.system_slug?(slug),
|
|
do: &Page.system_changeset/2,
|
|
else: &Page.custom_changeset/2
|
|
|
|
result =
|
|
case existing do
|
|
nil ->
|
|
%Page{}
|
|
|> changeset_fn.(Map.merge(%{slug: slug}, attrs))
|
|
|> Repo.insert()
|
|
|
|
%Page{} = page ->
|
|
page
|
|
|> changeset_fn.(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
case result do
|
|
{:ok, page} ->
|
|
PageCache.invalidate(slug)
|
|
enqueue_link_check(slug)
|
|
{:ok, page}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Creates a new custom page.
|
|
|
|
Returns `{:ok, page}` or `{:error, changeset}`.
|
|
"""
|
|
def create_custom_page(attrs) do
|
|
result =
|
|
%Page{}
|
|
|> Page.custom_changeset(attrs)
|
|
|> Repo.insert()
|
|
|
|
case result do
|
|
{:ok, page} ->
|
|
PageCache.put(page.slug, page_to_map(page))
|
|
{:ok, page}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Updates a custom page. Creates an auto redirect if the slug changes.
|
|
|
|
Returns `{:ok, page}` or `{:error, changeset}`.
|
|
"""
|
|
def update_custom_page(%Page{type: "custom"} = page, attrs) do
|
|
old_slug = page.slug
|
|
|
|
result =
|
|
page
|
|
|> Page.custom_changeset(attrs)
|
|
|> Repo.update()
|
|
|
|
case result do
|
|
{:ok, updated} ->
|
|
PageCache.invalidate(old_slug)
|
|
PageCache.put(updated.slug, page_to_map(updated))
|
|
|
|
if old_slug != updated.slug do
|
|
Berrypod.Redirects.create_auto(%{
|
|
from_path: "/#{old_slug}",
|
|
to_path: "/#{updated.slug}",
|
|
source: "auto_slug_change"
|
|
})
|
|
end
|
|
|
|
enqueue_link_check(updated.slug)
|
|
{:ok, updated}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
def update_custom_page(%Page{type: "system"}, _attrs), do: {:error, :system_page}
|
|
|
|
@doc """
|
|
Deletes a custom page.
|
|
|
|
Returns `{:ok, page}` or `{:error, :system_page}`.
|
|
"""
|
|
def delete_custom_page(%Page{type: "custom"} = page) do
|
|
case Repo.delete(page) do
|
|
{:ok, deleted} ->
|
|
PageCache.invalidate(deleted.slug)
|
|
{:ok, deleted}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
def delete_custom_page(%Page{type: "system"}), do: {:error, :system_page}
|
|
|
|
@doc """
|
|
Duplicates a custom page, creating a draft copy with a "-copy" slug suffix.
|
|
"""
|
|
def duplicate_custom_page(%Page{type: "custom"} = page) do
|
|
new_slug = find_available_slug(page.slug <> "-copy")
|
|
|
|
create_custom_page(%{
|
|
"slug" => new_slug,
|
|
"title" => page.title <> " (copy)",
|
|
"blocks" => page.blocks,
|
|
"published" => false,
|
|
"meta_description" => page.meta_description,
|
|
"show_in_nav" => false
|
|
})
|
|
end
|
|
|
|
defp find_available_slug(slug) do
|
|
if Repo.one(from p in Page, where: p.slug == ^slug, select: p.id) do
|
|
find_available_slug(slug <> "-2")
|
|
else
|
|
slug
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Resets a page to its default block list. Deletes the DB row (if any)
|
|
and invalidates the cache.
|
|
"""
|
|
def reset_page(slug) do
|
|
case Repo.one(from p in Page, where: p.slug == ^slug) do
|
|
nil -> :ok
|
|
page -> Repo.delete(page)
|
|
end
|
|
|
|
PageCache.invalidate(slug)
|
|
:ok
|
|
end
|
|
|
|
# ── Block data loading ────────────────────────────────────────────
|
|
|
|
@doc """
|
|
Runs data loaders for all blocks on a page, returning a merged map
|
|
of extra assigns.
|
|
|
|
Only blocks with a `data_loader` in the BlockTypes registry trigger
|
|
a query. Blocks without data loaders rely on global or page-context assigns.
|
|
"""
|
|
def load_block_data(blocks, assigns) do
|
|
BlockTypes.load_data(blocks, assigns)
|
|
end
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
|
|
defp enqueue_link_check(slug) do
|
|
Oban.insert(Berrypod.Workers.DeadLinkCheckerWorker.new(%{"page_slug" => slug}))
|
|
end
|
|
|
|
defp page_to_map(%Page{} = page) do
|
|
%{
|
|
slug: page.slug,
|
|
title: page.title,
|
|
blocks: page.blocks,
|
|
type: page.type || "system",
|
|
published: page.published,
|
|
meta_description: page.meta_description,
|
|
meta_robots: page.meta_robots,
|
|
focus_keyword: page.focus_keyword,
|
|
og_image_id: page.og_image_id,
|
|
show_in_nav: page.show_in_nav,
|
|
nav_label: page.nav_label,
|
|
nav_position: page.nav_position,
|
|
url_slug: page.url_slug
|
|
}
|
|
end
|
|
|
|
# ── URL customisation ────────────────────────────────────────────
|
|
|
|
@doc """
|
|
Updates a page's custom URL slug. Creates a redirect from the old
|
|
effective URL to the new one, and invalidates the R module cache.
|
|
|
|
Pass nil or empty string to clear the custom URL.
|
|
"""
|
|
def update_page_url_slug(%Page{} = page, url_slug) do
|
|
old_effective_url = Page.effective_url(page)
|
|
|
|
# Normalise empty string to nil
|
|
url_slug = if url_slug == "", do: nil, else: url_slug
|
|
|
|
changeset_fn =
|
|
if Page.system_slug?(page.slug),
|
|
do: &Page.system_changeset/2,
|
|
else: &Page.custom_changeset/2
|
|
|
|
result =
|
|
page
|
|
|> changeset_fn.(%{url_slug: url_slug})
|
|
|> Repo.update()
|
|
|
|
case result do
|
|
{:ok, updated} ->
|
|
new_effective_url = Page.effective_url(updated)
|
|
|
|
# Create redirect if URL changed
|
|
if old_effective_url != new_effective_url do
|
|
# Delete any stale redirect FROM the new URL (it's now a live page)
|
|
delete_stale_redirect("/#{new_effective_url}")
|
|
|
|
# Create redirect from old URL to new URL
|
|
Berrypod.Redirects.create_auto(%{
|
|
from_path: "/#{old_effective_url}",
|
|
to_path: "/#{new_effective_url}",
|
|
source: "auto_slug_change"
|
|
})
|
|
end
|
|
|
|
# Invalidate caches synchronously to avoid race conditions
|
|
PageCache.invalidate(page.slug)
|
|
BerrypodWeb.R.invalidate_all_sync()
|
|
|
|
{:ok, updated}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
def update_page_url_slug(slug, url_slug) when is_binary(slug) do
|
|
case get_page_struct(slug) do
|
|
nil ->
|
|
# Page doesn't exist in DB - create it from defaults if it's a system page
|
|
if Page.system_slug?(slug) do
|
|
defaults = Defaults.for_slug(slug)
|
|
|
|
case save_page(slug, defaults) do
|
|
{:ok, page} -> update_page_url_slug(page, url_slug)
|
|
error -> error
|
|
end
|
|
else
|
|
{:error, :not_found}
|
|
end
|
|
|
|
page ->
|
|
update_page_url_slug(page, url_slug)
|
|
end
|
|
end
|
|
|
|
# When a URL becomes a live page, delete any redirect from that URL
|
|
defp delete_stale_redirect(path) do
|
|
case Repo.one(from r in Berrypod.Redirects.Redirect, where: r.from_path == ^path) do
|
|
%Berrypod.Redirects.Redirect{} = redirect ->
|
|
Berrypod.Redirects.delete_redirect(redirect)
|
|
|
|
nil ->
|
|
:ok
|
|
end
|
|
end
|
|
end
|