diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index 603da11..d0f70eb 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -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 diff --git a/lib/berrypod/pages.ex b/lib/berrypod/pages.ex index 019d01b..007d7b1 100644 --- a/lib/berrypod/pages.ex +++ b/lib/berrypod/pages.ex @@ -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 diff --git a/lib/berrypod/pages/defaults.ex b/lib/berrypod/pages/defaults.ex index eddd1de..ffec48d 100644 --- a/lib/berrypod/pages/defaults.ex +++ b/lib/berrypod/pages/defaults.ex @@ -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 diff --git a/lib/berrypod/pages/page.ex b/lib/berrypod/pages/page.ex index ccfcc01..e8edc8b 100644 --- a/lib/berrypod/pages/page.ex +++ b/lib/berrypod/pages/page.ex @@ -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 diff --git a/lib/berrypod/pages/page_cache.ex b/lib/berrypod/pages/page_cache.ex index c6fc22e..859f0a5 100644 --- a/lib/berrypod/pages/page_cache.ex +++ b/lib/berrypod/pages/page_cache.ex @@ -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 diff --git a/priv/repo/migrations/20260228014939_add_custom_page_fields.exs b/priv/repo/migrations/20260228014939_add_custom_page_fields.exs new file mode 100644 index 0000000..30107b8 --- /dev/null +++ b/priv/repo/migrations/20260228014939_add_custom_page_fields.exs @@ -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 diff --git a/test/berrypod/pages_test.exs b/test/berrypod/pages_test.exs index 11a8e16..20bf859 100644 --- a/test/berrypod/pages_test.exs +++ b/test/berrypod/pages_test.exs @@ -46,11 +46,24 @@ defmodule Berrypod.PagesTest do assert is_map(block["settings"]), "block missing settings" 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 - describe "list_pages/0" do + describe "list_system_pages/0" do test "returns all 14 pages" do - pages = Pages.list_pages() + pages = Pages.list_system_pages() assert length(pages) == 14 slugs = Enum.map(pages, & &1.slug) @@ -60,6 +73,43 @@ defmodule Berrypod.PagesTest do 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 test "creates a new page in the DB" do blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}] @@ -91,14 +141,144 @@ defmodule Berrypod.PagesTest do assert page.title == "New home" end - test "rejects invalid slug" do + test "rejects invalid slug format" do assert {:error, changeset} = - Pages.save_page("nonexistent", %{title: "Bad", blocks: []}) + Pages.save_page("UPPER CASE", %{title: "Bad", blocks: []}) assert errors_on(changeset).slug 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 test "restores defaults after customisation" do {:ok, _} = @@ -220,6 +400,20 @@ defmodule Berrypod.PagesTest do refute Map.has_key?(cart_types, "product_hero") 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 for {type, def} <- BlockTypes.all() do assert is_binary(def.name), "#{type} missing name" @@ -313,6 +507,14 @@ defmodule Berrypod.PagesTest do ] 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 id1 = Defaults.generate_block_id() id2 = Defaults.generate_block_id() @@ -344,4 +546,22 @@ defmodule Berrypod.PagesTest do assert :miss = PageCache.get(slug) 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