diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0b98412..a91e2ce 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.213785081Z","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} {"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:04.676875214Z","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} {"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.395327878Z"} -{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.478922912Z"} +{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} {"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:44.673871753Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} {"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:28.051938506Z","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.519698002Z"} diff --git a/blogex/lib/blogex.ex b/blogex/lib/blogex.ex index c0cf2ce..4e37606 100644 --- a/blogex/lib/blogex.ex +++ b/blogex/lib/blogex.ex @@ -119,5 +119,6 @@ defmodule Blogex do defdelegate get_blog!(blog_id), to: Blogex.Registry defdelegate get_blog(blog_id), to: Blogex.Registry defdelegate all_posts, to: Blogex.Registry + defdelegate all_posts_unfiltered, to: Blogex.Registry defdelegate all_tags, to: Blogex.Registry end diff --git a/blogex/lib/blogex/blog.ex b/blogex/lib/blogex/blog.ex index 8ae265d..79cd6ac 100644 --- a/blogex/lib/blogex/blog.ex +++ b/blogex/lib/blogex/blog.ex @@ -73,12 +73,17 @@ defmodule Blogex.Blog do @doc "Returns the base URL path for this blog." def base_path, do: @blog_base_path + @doc "Returns all compiled posts regardless of published status or date." + def unfiltered_posts, do: @posts + @doc "Returns all visible posts, newest first. Drafts are included in dev/test." def all_posts do + today = Date.utc_today() + if Blogex.show_drafts?() do - @posts + Enum.filter(@posts, &(not Date.after?(&1.date, today))) else - Enum.filter(@posts, & &1.published) + Enum.filter(@posts, &(&1.published and not Date.after?(&1.date, today))) end end @@ -86,7 +91,12 @@ defmodule Blogex.Blog do def recent_posts(n \\ 5), do: Enum.take(all_posts(), n) @doc "Returns all unique tags across all published posts." - def all_tags, do: @tags + def all_tags do + all_posts() + |> Enum.flat_map(& &1.tags) + |> Enum.uniq() + |> Enum.sort() + end @doc "Returns all published posts matching the given tag." def posts_by_tag(tag) do diff --git a/blogex/lib/blogex/registry.ex b/blogex/lib/blogex/registry.ex index 9f02edc..5bde1f0 100644 --- a/blogex/lib/blogex/registry.ex +++ b/blogex/lib/blogex/registry.ex @@ -37,6 +37,13 @@ defmodule Blogex.Registry do |> Enum.sort_by(& &1.date, {:desc, Date}) end + @doc "Returns all posts from all blogs (unfiltered), sorted newest first." + def all_posts_unfiltered do + blogs() + |> Enum.flat_map(& &1.unfiltered_posts()) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + @doc "Returns all unique tags across all blogs." def all_tags do blogs() diff --git a/blogex/test/blogex/blog_test.exs b/blogex/test/blogex/blog_test.exs index fe89e9c..8faa9ce 100644 --- a/blogex/test/blogex/blog_test.exs +++ b/blogex/test/blogex/blog_test.exs @@ -15,6 +15,24 @@ defmodule Blogex.BlogTest do assert "draft-post" not in ids end + test "excludes future-dated posts", %{blog: blog} do + ids = blog.all_posts() |> Enum.map(& &1.id) + + refute "future-post" in ids + end + + test "includes today-dated published posts" do + {:ok, _} = FakeBlog.start([ + build(id: "today", date: Date.utc_today(), published: true), + build(id: "tomorrow", date: Date.add(Date.utc_today(), 1), published: true) + ]) + + ids = FakeBlog.all_posts() |> Enum.map(& &1.id) + + assert "today" in ids + refute "tomorrow" in ids + end + test "returns posts newest first", %{blog: blog} do dates = blog.all_posts() |> Enum.map(& &1.date) @@ -23,6 +41,13 @@ defmodule Blogex.BlogTest do end describe "recent_posts/1" do + test "excludes future-dated posts", %{blog: blog} do + posts = blog.recent_posts(100) + ids = Enum.map(posts, & &1.id) + + refute "future-post" in ids + end + test "returns at most n posts", %{blog: blog} do assert length(blog.recent_posts(2)) == 2 end @@ -35,6 +60,12 @@ defmodule Blogex.BlogTest do end describe "posts_by_tag/1" do + test "excludes future-dated posts", %{blog: blog} do + posts = blog.posts_by_tag("future-only") + + assert posts == [] + end + test "returns only posts with the given tag", %{blog: blog} do posts = blog.posts_by_tag("testing") @@ -59,6 +90,10 @@ defmodule Blogex.BlogTest do end describe "all_tags/0" do + test "excludes tags only on future-dated posts", %{blog: blog} do + refute "future-only" in blog.all_tags() + end + test "returns unique sorted tags from published posts", %{blog: blog} do tags = blog.all_tags() diff --git a/blogex/test/blogex/registry_test.exs b/blogex/test/blogex/registry_test.exs index 50a8ea2..795880b 100644 --- a/blogex/test/blogex/registry_test.exs +++ b/blogex/test/blogex/registry_test.exs @@ -8,12 +8,24 @@ defmodule Blogex.RegistryTest do def blog_id, do: :alpha def all_posts, do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)] def all_tags, do: ["elixir"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha), + Blogex.Test.PostBuilder.build(id: "a-draft", date: ~D[2026-03-05], blog: :alpha, published: false) + ] end defmodule BetaBlog do def blog_id, do: :beta def all_posts, do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)] def all_tags, do: ["devops"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta), + Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta) + ] end setup do @@ -77,6 +89,21 @@ defmodule Blogex.RegistryTest do end end + describe "all_posts_unfiltered/0" do + test "returns all posts including drafts and future-dated" do + ids = Registry.all_posts_unfiltered() |> Enum.map(& &1.id) + assert "a1" in ids + assert "a-draft" in ids + assert "b1" in ids + assert "b-future" in ids + end + + test "sorts by date descending" do + dates = Registry.all_posts_unfiltered() |> Enum.map(& &1.date) + assert dates == Enum.sort(dates, {:desc, Date}) + end + end + describe "blogs_map/0" do test "returns map keyed by blog_id" do map = Registry.blogs_map() diff --git a/blogex/test/support/fake_blog.ex b/blogex/test/support/fake_blog.ex index 918588e..104a8c3 100644 --- a/blogex/test/support/fake_blog.ex +++ b/blogex/test/support/fake_blog.ex @@ -51,9 +51,16 @@ defmodule Blogex.Test.FakeBlog do def description, do: get(:description) def base_path, do: get(:base_path) - def all_posts do + def unfiltered_posts do get(:posts) - |> Enum.filter(& &1.published) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def all_posts do + today = Date.utc_today() + + get(:posts) + |> Enum.filter(&(&1.published and not Date.after?(&1.date, today))) |> Enum.sort_by(& &1.date, {:desc, Date}) end diff --git a/blogex/test/support/setup.ex b/blogex/test/support/setup.ex index 83903dd..7dc15fa 100644 --- a/blogex/test/support/setup.ex +++ b/blogex/test/support/setup.ex @@ -44,6 +44,12 @@ defmodule Blogex.Test.Setup do date: ~D[2026-03-12], tags: ["elixir"], published: false + ), + build( + id: "future-post", + date: ~D[2099-01-01], + tags: ["future-only"], + published: true ) ] end