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:
@@ -343,7 +343,7 @@ defmodule Berrypod.Media do
|
||||
end
|
||||
|
||||
defp find_page_usages(image_id) do
|
||||
Berrypod.Pages.list_pages()
|
||||
Berrypod.Pages.list_all_pages()
|
||||
|> Enum.flat_map(fn page ->
|
||||
page.blocks
|
||||
|> Enum.filter(fn block ->
|
||||
@@ -357,7 +357,7 @@ defmodule Berrypod.Media do
|
||||
end
|
||||
|
||||
defp scan_pages_for_image_ids do
|
||||
Berrypod.Pages.list_pages()
|
||||
Berrypod.Pages.list_all_pages()
|
||||
|> Enum.flat_map(fn page ->
|
||||
Enum.flat_map(page.blocks, fn block ->
|
||||
case get_in(block, ["settings", "image_id"]) do
|
||||
|
||||
@@ -5,7 +5,8 @@ defmodule Berrypod.Pages do
|
||||
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.
|
||||
Lookup order: ETS cache -> DB -> defaults (system pages only).
|
||||
Custom pages return nil when not in DB.
|
||||
"""
|
||||
|
||||
alias Berrypod.Repo
|
||||
@@ -18,8 +19,9 @@ defmodule Berrypod.Pages do
|
||||
@doc """
|
||||
Gets a page definition by slug.
|
||||
|
||||
Checks the ETS cache first, falls back to DB, then to built-in defaults.
|
||||
Returns a map with `:slug`, `:title`, and `:blocks` keys.
|
||||
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
|
||||
@@ -32,15 +34,28 @@ defmodule Berrypod.Pages do
|
||||
end
|
||||
|
||||
@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
|
||||
case Repo.one(from p in Page, where: p.slug == ^slug) do
|
||||
%Page{} = page -> page_to_map(page)
|
||||
nil -> Defaults.for_slug(slug)
|
||||
%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)
|
||||
|
||||
@@ -54,33 +69,53 @@ defmodule Berrypod.Pages do
|
||||
page_data
|
||||
end
|
||||
|
||||
@doc "Lists all pages with their current definitions."
|
||||
def list_pages do
|
||||
Page.valid_slugs()
|
||||
@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
|
||||
|
||||
# ── Write ─────────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
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}`.
|
||||
"""
|
||||
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{}
|
||||
|> Page.changeset(Map.merge(%{slug: slug}, attrs))
|
||||
|> changeset_fn.(Map.merge(%{slug: slug}, attrs))
|
||||
|> Repo.insert()
|
||||
|
||||
%Page{} = page ->
|
||||
page
|
||||
|> Page.changeset(attrs)
|
||||
|> changeset_fn.(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@@ -94,6 +129,80 @@ defmodule Berrypod.Pages do
|
||||
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 """
|
||||
Resets a page to its default block list. Deletes the DB row (if any)
|
||||
and invalidates the cache.
|
||||
@@ -124,6 +233,16 @@ defmodule Berrypod.Pages do
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@ defmodule Berrypod.Pages.Defaults do
|
||||
|
||||
@doc "Returns default definitions for all pages."
|
||||
def all do
|
||||
Berrypod.Pages.Page.valid_slugs()
|
||||
Berrypod.Pages.Page.system_slugs()
|
||||
|> Enum.map(&for_slug/1)
|
||||
end
|
||||
|
||||
@@ -33,6 +33,10 @@ defmodule Berrypod.Pages.Defaults do
|
||||
defp title("order_detail"), do: "Order detail"
|
||||
defp title("error"), do: "Error"
|
||||
|
||||
defp title(slug) do
|
||||
slug |> String.replace(~r/[-_]/, " ") |> String.capitalize()
|
||||
end
|
||||
|
||||
# ── Block lists ─────────────────────────────────────────────────
|
||||
|
||||
defp blocks("home") do
|
||||
@@ -200,6 +204,8 @@ defmodule Berrypod.Pages.Defaults do
|
||||
]
|
||||
end
|
||||
|
||||
defp blocks(_slug), do: []
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
defp block(type, settings \\ %{}) do
|
||||
|
||||
@@ -5,27 +5,79 @@ defmodule Berrypod.Pages.Page do
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@valid_slugs ~w(
|
||||
@system_slugs ~w(
|
||||
home about delivery privacy terms contact
|
||||
collection pdp cart search
|
||||
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
|
||||
field :slug, :string
|
||||
field :title, :string
|
||||
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)
|
||||
end
|
||||
|
||||
def changeset(page, attrs) do
|
||||
def system_changeset(page, attrs) do
|
||||
page
|
||||
|> cast(attrs, [:slug, :title, :blocks])
|
||||
|> validate_required([:slug, :title, :blocks])
|
||||
|> validate_inclusion(:slug, @valid_slugs)
|
||||
|> validate_inclusion(:slug, @system_slugs)
|
||||
|> unique_constraint(:slug)
|
||||
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
|
||||
|
||||
@@ -49,12 +49,25 @@ defmodule Berrypod.Pages.PageCache do
|
||||
@doc "Warms the cache by loading all pages from the DB."
|
||||
def warm do
|
||||
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)
|
||||
put(slug, page)
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user