add custom page data model with split changesets and CRUD context

Stage 1 of custom CMS pages. Adds type/published/meta/nav fields to
pages schema, splits changeset into system vs custom (with slug format
validation and reserved path exclusion), adds create/update/delete
functions with auto-redirect on slug change, and warms custom pages
in ETS cache. 62 pages tests, 1426 total.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-28 01:56:19 +00:00
parent 356e336eef
commit cf627bd585
7 changed files with 449 additions and 25 deletions

View File

@ -343,7 +343,7 @@ defmodule Berrypod.Media do
end end
defp find_page_usages(image_id) do defp find_page_usages(image_id) do
Berrypod.Pages.list_pages() Berrypod.Pages.list_all_pages()
|> Enum.flat_map(fn page -> |> Enum.flat_map(fn page ->
page.blocks page.blocks
|> Enum.filter(fn block -> |> Enum.filter(fn block ->
@ -357,7 +357,7 @@ defmodule Berrypod.Media do
end end
defp scan_pages_for_image_ids do defp scan_pages_for_image_ids do
Berrypod.Pages.list_pages() Berrypod.Pages.list_all_pages()
|> Enum.flat_map(fn page -> |> Enum.flat_map(fn page ->
Enum.flat_map(page.blocks, fn block -> Enum.flat_map(page.blocks, fn block ->
case get_in(block, ["settings", "image_id"]) do case get_in(block, ["settings", "image_id"]) do

View File

@ -5,7 +5,8 @@ defmodule Berrypod.Pages do
Every page is a flat list of blocks stored as JSON. This module provides 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. the public API for reading, writing, and loading block data for pages.
Lookup order: ETS cache -> DB -> defaults. Lookup order: ETS cache -> DB -> defaults (system pages only).
Custom pages return nil when not in DB.
""" """
alias Berrypod.Repo alias Berrypod.Repo
@ -18,8 +19,9 @@ defmodule Berrypod.Pages do
@doc """ @doc """
Gets a page definition by slug. Gets a page definition by slug.
Checks the ETS cache first, falls back to DB, then to built-in defaults. Checks the ETS cache first, falls back to DB, then to built-in defaults
Returns a map with `:slug`, `:title`, and `:blocks` keys. (for system pages only). Returns a map with page fields, or nil for
unknown custom slugs.
""" """
def get_page(slug) do def get_page(slug) do
case PageCache.get(slug) do case PageCache.get(slug) do
@ -32,15 +34,28 @@ defmodule Berrypod.Pages do
end end
@doc """ @doc """
Gets a page from the DB, falling back to defaults. Used by PageCache.warm/0. 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 def get_page_from_db(slug) do
case Repo.one(from p in Page, where: p.slug == ^slug) do case Repo.one(from p in Page, where: p.slug == ^slug) do
%Page{} = page -> page_to_map(page) %Page{} = page ->
nil -> Defaults.for_slug(slug) page_to_map(page)
nil ->
if Page.system_slug?(slug) do
Defaults.for_slug(slug)
else
nil
end
end 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 defp get_page_uncached(slug) do
page_data = get_page_from_db(slug) page_data = get_page_from_db(slug)
@ -54,33 +69,53 @@ defmodule Berrypod.Pages do
page_data page_data
end end
@doc "Lists all pages with their current definitions." @doc "Lists all 14 system pages with their current definitions."
def list_pages do def list_system_pages do
Page.valid_slugs() Page.system_slugs()
|> Enum.map(&get_page/1) |> Enum.map(&get_page/1)
end 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
# ── Write ───────────────────────────────────────────────────────── # ── Write ─────────────────────────────────────────────────────────
@doc """ @doc """
Saves a page's block list. Creates or updates the DB row and invalidates Saves a page's block list. Creates or updates the DB row and invalidates
the cache. the cache. Uses the appropriate changeset based on page type.
Returns `{:ok, page}` or `{:error, changeset}`. Returns `{:ok, page}` or `{:error, changeset}`.
""" """
def save_page(slug, attrs) do def save_page(slug, attrs) do
existing = Repo.one(from p in Page, where: p.slug == ^slug) 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 = result =
case existing do case existing do
nil -> nil ->
%Page{} %Page{}
|> Page.changeset(Map.merge(%{slug: slug}, attrs)) |> changeset_fn.(Map.merge(%{slug: slug}, attrs))
|> Repo.insert() |> Repo.insert()
%Page{} = page -> %Page{} = page ->
page page
|> Page.changeset(attrs) |> changeset_fn.(attrs)
|> Repo.update() |> Repo.update()
end end
@ -94,6 +129,80 @@ defmodule Berrypod.Pages do
end end
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
{: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 """ @doc """
Resets a page to its default block list. Deletes the DB row (if any) Resets a page to its default block list. Deletes the DB row (if any)
and invalidates the cache. and invalidates the cache.
@ -124,6 +233,16 @@ defmodule Berrypod.Pages do
# ── Helpers ─────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────
defp page_to_map(%Page{} = page) do defp page_to_map(%Page{} = page) do
%{slug: page.slug, title: page.title, blocks: page.blocks} %{
slug: page.slug,
title: page.title,
blocks: page.blocks,
type: page.type || "system",
published: page.published,
meta_description: page.meta_description,
show_in_nav: page.show_in_nav,
nav_label: page.nav_label,
nav_position: page.nav_position
}
end end
end end

View File

@ -12,7 +12,7 @@ defmodule Berrypod.Pages.Defaults do
@doc "Returns default definitions for all pages." @doc "Returns default definitions for all pages."
def all do def all do
Berrypod.Pages.Page.valid_slugs() Berrypod.Pages.Page.system_slugs()
|> Enum.map(&for_slug/1) |> Enum.map(&for_slug/1)
end end
@ -33,6 +33,10 @@ defmodule Berrypod.Pages.Defaults do
defp title("order_detail"), do: "Order detail" defp title("order_detail"), do: "Order detail"
defp title("error"), do: "Error" defp title("error"), do: "Error"
defp title(slug) do
slug |> String.replace(~r/[-_]/, " ") |> String.capitalize()
end
# ── Block lists ───────────────────────────────────────────────── # ── Block lists ─────────────────────────────────────────────────
defp blocks("home") do defp blocks("home") do
@ -200,6 +204,8 @@ defmodule Berrypod.Pages.Defaults do
] ]
end end
defp blocks(_slug), do: []
# ── Helpers ───────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────
defp block(type, settings \\ %{}) do defp block(type, settings \\ %{}) do

View File

@ -5,27 +5,79 @@ defmodule Berrypod.Pages.Page do
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@valid_slugs ~w( @system_slugs ~w(
home about delivery privacy terms contact home about delivery privacy terms contact
collection pdp cart search collection pdp cart search
checkout_success orders order_detail error checkout_success orders order_detail error
) )
@reserved_paths ~w(
about delivery privacy terms contact
collections products cart search checkout orders
coming-soon admin health image_cache images favicon
sitemap.xml robots.txt setup dev
)
schema "pages" do schema "pages" do
field :slug, :string field :slug, :string
field :title, :string field :title, :string
field :blocks, {:array, :map}, default: [] field :blocks, {:array, :map}, default: []
field :type, :string, default: "system"
field :published, :boolean, default: true
field :meta_description, :string
field :show_in_nav, :boolean, default: false
field :nav_label, :string
field :nav_position, :integer
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
def changeset(page, attrs) do def system_changeset(page, attrs) do
page page
|> cast(attrs, [:slug, :title, :blocks]) |> cast(attrs, [:slug, :title, :blocks])
|> validate_required([:slug, :title, :blocks]) |> validate_required([:slug, :title, :blocks])
|> validate_inclusion(:slug, @valid_slugs) |> validate_inclusion(:slug, @system_slugs)
|> unique_constraint(:slug) |> unique_constraint(:slug)
end end
def valid_slugs, do: @valid_slugs def custom_changeset(page, attrs) do
page
|> cast(attrs, [
:slug,
:title,
:blocks,
:type,
:published,
:meta_description,
:show_in_nav,
:nav_label,
:nav_position
])
|> validate_required([:slug, :title])
|> validate_format(:slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
message: "must be lowercase letters, numbers, and hyphens only"
)
|> validate_exclusion(:slug, @system_slugs ++ @reserved_paths, message: "is reserved")
|> validate_length(:slug, max: 100)
|> validate_length(:title, max: 200)
|> validate_length(:meta_description, max: 300)
|> put_defaults()
|> unique_constraint(:slug)
end
defp put_defaults(changeset) do
changeset
|> put_change(:type, "custom")
|> maybe_put(:blocks, [])
end
# Only puts the change if no explicit value was provided in the changeset
defp maybe_put(changeset, field, default) do
if get_change(changeset, field), do: changeset, else: put_change(changeset, field, default)
end
def system_slugs, do: @system_slugs
def reserved_paths, do: @reserved_paths
def system_slug?(slug), do: slug in @system_slugs
def reserved_path?(slug), do: slug in @reserved_paths
end end

View File

@ -49,12 +49,25 @@ defmodule Berrypod.Pages.PageCache do
@doc "Warms the cache by loading all pages from the DB." @doc "Warms the cache by loading all pages from the DB."
def warm do def warm do
alias Berrypod.Pages alias Berrypod.Pages
alias Berrypod.Pages.Page
for slug <- Berrypod.Pages.Page.valid_slugs() do for slug <- Page.system_slugs() do
page = Pages.get_page_from_db(slug) page = Pages.get_page_from_db(slug)
put(slug, page) put(slug, page)
end end
import Ecto.Query
custom_slugs =
Berrypod.Repo.all(from p in Page, where: p.type == "custom", select: p.slug)
for slug <- custom_slugs do
case Pages.get_page_from_db(slug) do
nil -> :ok
page -> put(slug, page)
end
end
:ok :ok
end end

View File

@ -0,0 +1,14 @@
defmodule Berrypod.Repo.Migrations.AddCustomPageFields do
use Ecto.Migration
def change do
alter table(:pages) do
add :type, :string, default: "system"
add :published, :boolean, default: true
add :meta_description, :string
add :show_in_nav, :boolean, default: false
add :nav_label, :string
add :nav_position, :integer
end
end
end

View File

@ -46,11 +46,24 @@ defmodule Berrypod.PagesTest do
assert is_map(block["settings"]), "block missing settings" assert is_map(block["settings"]), "block missing settings"
end end
end end
test "returns nil for unknown custom slug" do
assert Pages.get_page("does-not-exist") == nil
end
test "returns page data for created custom page" do
{:ok, _} = Pages.create_custom_page(%{slug: "my-page", title: "My page"})
page = Pages.get_page("my-page")
assert page.slug == "my-page"
assert page.title == "My page"
assert page.type == "custom"
end
end end
describe "list_pages/0" do describe "list_system_pages/0" do
test "returns all 14 pages" do test "returns all 14 pages" do
pages = Pages.list_pages() pages = Pages.list_system_pages()
assert length(pages) == 14 assert length(pages) == 14
slugs = Enum.map(pages, & &1.slug) slugs = Enum.map(pages, & &1.slug)
@ -60,6 +73,43 @@ defmodule Berrypod.PagesTest do
end end
end end
describe "list_custom_pages/0" do
test "returns empty list when no custom pages exist" do
assert Pages.list_custom_pages() == []
end
test "returns custom pages ordered by title" do
{:ok, _} = Pages.create_custom_page(%{slug: "zebra", title: "Zebra"})
{:ok, _} = Pages.create_custom_page(%{slug: "apple", title: "Apple"})
{:ok, _} = Pages.create_custom_page(%{slug: "mango", title: "Mango"})
pages = Pages.list_custom_pages()
titles = Enum.map(pages, & &1.title)
assert titles == ["Apple", "Mango", "Zebra"]
end
test "does not include system pages" do
{:ok, _} = Pages.save_page("home", %{title: "Home page", blocks: []})
{:ok, _} = Pages.create_custom_page(%{slug: "custom-one", title: "Custom"})
pages = Pages.list_custom_pages()
slugs = Enum.map(pages, & &1.slug)
assert "custom-one" in slugs
refute "home" in slugs
end
end
describe "list_all_pages/0" do
test "includes both system and custom pages" do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
pages = Pages.list_all_pages()
slugs = Enum.map(pages, & &1.slug)
assert "home" in slugs
assert "faq" in slugs
end
end
describe "save_page/2" do describe "save_page/2" do
test "creates a new page in the DB" do test "creates a new page in the DB" do
blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}] blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}]
@ -91,14 +141,144 @@ defmodule Berrypod.PagesTest do
assert page.title == "New home" assert page.title == "New home"
end end
test "rejects invalid slug" do test "rejects invalid slug format" do
assert {:error, changeset} = assert {:error, changeset} =
Pages.save_page("nonexistent", %{title: "Bad", blocks: []}) Pages.save_page("UPPER CASE", %{title: "Bad", blocks: []})
assert errors_on(changeset).slug assert errors_on(changeset).slug
end end
end end
describe "create_custom_page/1" do
test "creates a custom page with valid slug" do
assert {:ok, page} = Pages.create_custom_page(%{slug: "our-story", title: "Our story"})
assert page.slug == "our-story"
assert page.title == "Our story"
assert page.type == "custom"
assert page.blocks == []
assert page.published == true
end
test "rejects duplicate slug" do
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
assert {:error, changeset} = Pages.create_custom_page(%{slug: "faq", title: "FAQ 2"})
assert errors_on(changeset).slug
end
test "rejects system slug" do
assert {:error, changeset} = Pages.create_custom_page(%{slug: "home", title: "Home"})
assert errors_on(changeset).slug
end
test "rejects reserved path" do
assert {:error, changeset} = Pages.create_custom_page(%{slug: "admin", title: "Admin"})
assert errors_on(changeset).slug
end
test "rejects invalid slug format" do
assert {:error, changeset} =
Pages.create_custom_page(%{slug: "My Page", title: "My page"})
assert errors_on(changeset).slug
end
test "rejects slug with leading hyphen" do
assert {:error, changeset} = Pages.create_custom_page(%{slug: "-bad", title: "Bad"})
assert errors_on(changeset).slug
end
test "rejects slug with trailing hyphen" do
assert {:error, changeset} = Pages.create_custom_page(%{slug: "bad-", title: "Bad"})
assert errors_on(changeset).slug
end
test "accepts single-character slug" do
assert {:ok, page} = Pages.create_custom_page(%{slug: "x", title: "X"})
assert page.slug == "x"
end
test "populates cache on create" do
{:ok, _} = Pages.create_custom_page(%{slug: "cached-page", title: "Cached"})
assert {:ok, data} = PageCache.get("cached-page")
assert data.title == "Cached"
assert data.type == "custom"
end
end
describe "delete_custom_page/1" do
test "deletes a custom page" do
{:ok, _} = Pages.create_custom_page(%{slug: "temp", title: "Temp"})
page_struct = Pages.get_page_struct("temp")
assert {:ok, _} = Pages.delete_custom_page(page_struct)
assert Pages.get_page("temp") == nil
end
test "refuses to delete a system page" do
{:ok, page} = Pages.save_page("home", %{title: "Home page", blocks: []})
assert {:error, :system_page} = Pages.delete_custom_page(page)
end
test "invalidates cache on delete" do
{:ok, _} = Pages.create_custom_page(%{slug: "bye", title: "Bye"})
page_struct = Pages.get_page_struct("bye")
Pages.delete_custom_page(page_struct)
assert :miss = PageCache.get("bye")
end
end
describe "update_custom_page/2" do
test "updates title" do
{:ok, _} = Pages.create_custom_page(%{slug: "updates", title: "V1"})
page = Pages.get_page_struct("updates")
assert {:ok, updated} = Pages.update_custom_page(page, %{title: "V2"})
assert updated.title == "V2"
end
test "slug change creates redirect" do
{:ok, _} = Pages.create_custom_page(%{slug: "old-name", title: "Page"})
page = Pages.get_page_struct("old-name")
assert {:ok, updated} = Pages.update_custom_page(page, %{slug: "new-name"})
assert updated.slug == "new-name"
assert {:ok, redirect} = Berrypod.Redirects.lookup("/old-name")
assert redirect.to_path == "/new-name"
end
test "slug change invalidates old cache and populates new" do
{:ok, _} = Pages.create_custom_page(%{slug: "rename-me", title: "Page"})
page = Pages.get_page_struct("rename-me")
{:ok, _} = Pages.update_custom_page(page, %{slug: "renamed"})
assert :miss = PageCache.get("rename-me")
assert {:ok, cached} = PageCache.get("renamed")
assert cached.slug == "renamed"
end
test "updates meta and nav fields" do
{:ok, _} = Pages.create_custom_page(%{slug: "meta-test", title: "Meta"})
page = Pages.get_page_struct("meta-test")
{:ok, updated} =
Pages.update_custom_page(page, %{
meta_description: "A test page",
show_in_nav: true,
nav_label: "Test",
nav_position: 5
})
assert updated.meta_description == "A test page"
assert updated.show_in_nav == true
assert updated.nav_label == "Test"
assert updated.nav_position == 5
end
test "refuses to update a system page" do
{:ok, page} = Pages.save_page("home", %{title: "Home page", blocks: []})
assert {:error, :system_page} = Pages.update_custom_page(page, %{title: "Nope"})
end
end
describe "reset_page/1" do describe "reset_page/1" do
test "restores defaults after customisation" do test "restores defaults after customisation" do
{:ok, _} = {:ok, _} =
@ -220,6 +400,20 @@ defmodule Berrypod.PagesTest do
refute Map.has_key?(cart_types, "product_hero") refute Map.has_key?(cart_types, "product_hero")
end end
test "allowed_for/1 returns only portable blocks for custom pages" do
custom_types = BlockTypes.allowed_for("my-custom-page")
# Portable blocks are included
assert Map.has_key?(custom_types, "hero")
assert Map.has_key?(custom_types, "featured_products")
assert Map.has_key?(custom_types, "image_text")
# Page-specific blocks are excluded
refute Map.has_key?(custom_types, "cart_items")
refute Map.has_key?(custom_types, "product_hero")
refute Map.has_key?(custom_types, "contact_form")
end
test "every block type has required fields" do test "every block type has required fields" do
for {type, def} <- BlockTypes.all() do for {type, def} <- BlockTypes.all() do
assert is_binary(def.name), "#{type} missing name" assert is_binary(def.name), "#{type} missing name"
@ -313,6 +507,14 @@ defmodule Berrypod.PagesTest do
] ]
end end
test "unknown slug returns humanised title and empty blocks" do
page = Defaults.for_slug("our-story")
assert page.slug == "our-story"
assert page.title == "Our story"
assert page.blocks == []
end
test "generate_block_id/0 produces unique prefixed IDs" do test "generate_block_id/0 produces unique prefixed IDs" do
id1 = Defaults.generate_block_id() id1 = Defaults.generate_block_id()
id2 = Defaults.generate_block_id() id2 = Defaults.generate_block_id()
@ -344,4 +546,22 @@ defmodule Berrypod.PagesTest do
assert :miss = PageCache.get(slug) assert :miss = PageCache.get(slug)
end end
end end
describe "Page schema" do
test "system_slugs/0 returns 14 slugs" do
assert length(Page.system_slugs()) == 14
end
test "system_slug?/1 returns true for system slugs" do
assert Page.system_slug?("home")
assert Page.system_slug?("cart")
refute Page.system_slug?("my-page")
end
test "reserved_path?/1 returns true for reserved paths" do
assert Page.reserved_path?("admin")
assert Page.reserved_path?("collections")
refute Page.reserved_path?("faq")
end
end
end end