firehose/plans/scheduled-publishing.md
2026-04-02 09:56:49 +00:00

15 KiB

Plan: Scheduled Publishing & Author Dashboard

Created: 2026-04-01 Branch: main Status: draft Spec: specs/scheduled-publishing.allium

Goal

Add date-based post scheduling to Blogex so future-dated posts are hidden from public views and feeds, add session-based authentication gated to a single allowed email, and build a LiveView dashboard at /editor/dashboard where authenticated authors can see drafts and scheduled posts across all blogs.

Acceptance Criteria

  • all_posts() excludes posts where date > Date.utc_today() from public views
  • RSS and Atom feeds exclude future-dated posts
  • Tag pages exclude future-dated posts
  • Direct URL access (/blog/:blog_id/:slug) still shows any post regardless of date
  • get_post/get_post! are unfiltered (find from all compiled posts)
  • mix phx.gen.auth provides email/password authentication
  • Registration rejects non-matching emails with "registration is invite only."
  • Registration is disabled entirely when ALLOWED_REGISTRATION_EMAIL is unset
  • Login/registration pages are not linked from public navigation
  • Demo user seeded in dev only (demo@example.com / password123)
  • LiveView dashboard at /editor/dashboard requires authentication
  • Dashboard has two tabs: drafts and scheduled posts
  • Unified timeline across all blogs (not grouped)
  • Scheduled posts show "X days until live" countdown
  • Clicking a post navigates to the blog show page
  • Authenticated users see status banner on draft/scheduled posts ("Draft - not published" or "This post is scheduled for {date}")

Beads Issue Graph

Phase Bead Title Depends on
1 firehose-2wc Add date filtering to Blogex all_posts/0
1 firehose-1x3 Make get_post/get_post! unfiltered 2wc
1 firehose-1h8 Verify feeds exclude future-dated posts 2wc
1 firehose-vyw Verify router respects date filtering 2wc, 1x3
1 firehose-apw Integration tests for Phoenix 2wc, 1x3
2 firehose-dhh Run mix phx.gen.auth and configure
2 firehose-8zg Gate registration to ALLOWED_REGISTRATION_EMAIL dhh
2 firehose-pp3 Seed demo user in dev dhh
3 firehose-4nq Add post visibility and days_until_live helpers
3 firehose-ai8 Add unfiltered post access for dashboard
3 firehose-4yh Create LiveView editor dashboard 4nq, ai8, dhh
3 firehose-ra3 Show draft/scheduled status banners 4nq, dhh

Phase transitions

Each phase begins by running /agentic-dev-team:beads enrich to update its beads with current codebase context (file paths, line numbers, test patterns discovered in prior phases). This enriches the bead bodies with checkpoint details before starting work.

Steps

Step 1: Add date filtering to Blogex all_posts/0firehose-2wc

Complexity: standard RED: Add tests to blogex/test/blogex/blog_test.exs:

  • all_posts/0 excludes future-dated posts (add a post with date ~D[2099-01-01], published: true to FakeBlog — it should not appear)
  • all_posts/0 includes past-dated published posts (existing tests cover this)
  • all_posts/0 includes today-dated published posts
  • posts_by_tag/1 excludes future-dated posts
  • recent_posts/1 excludes future-dated posts
  • all_tags/0 excludes tags only on future-dated posts GREEN:
  • In blogex/lib/blogex/blog.ex all_posts/0: add date <= Date.utc_today() filter after the existing published filter
  • In blogex/test/support/fake_blog.ex all_posts/0: add same date filter REFACTOR: Extract the filtering predicate into a shared helper if both Blog and FakeBlog duplicate the logic Files: blogex/lib/blogex/blog.ex, blogex/test/blogex/blog_test.exs, blogex/test/support/fake_blog.ex, blogex/test/support/setup.ex Commit: Filter future-dated posts from public blog views

Step 2: Make get_post/get_post! search all compiled posts (unfiltered) — firehose-1x3

Complexity: standard RED: Add tests to blogex/test/blogex/blog_test.exs:

  • get_post!/1 returns a future-dated published post by slug
  • get_post/1 returns a future-dated published post by slug
  • get_post!/1 returns a draft post by slug (currently raises — this is a behaviour change)
  • get_post/1 returns a draft post by slug (currently returns nil — behaviour change) GREEN:
  • In blogex/lib/blogex/blog.ex: change get_post!/1 and get_post/1 to search @posts (the unfiltered compile-time list) instead of all_posts()
  • In blogex/test/support/fake_blog.ex: change get_post!/1 and get_post/1 to search from the full unfiltered posts list (Agent state :posts) instead of all_posts() REFACTOR: Update existing test that expects get_post!("draft-post") to raise — it should now succeed Files: blogex/lib/blogex/blog.ex, blogex/test/blogex/blog_test.exs, blogex/test/support/fake_blog.ex Commit: Allow direct access to draft and scheduled posts by slug

Step 3: Verify feeds exclude future-dated posts — firehose-1h8

Complexity: standard RED: Add tests to blogex/test/blogex/feed_test.exs:

  • RSS feed excludes future-dated published posts
  • Atom feed excludes future-dated published posts (These may already pass since feeds call all_posts() — confirm with a test using FakeBlog that includes a future-dated post) GREEN: Should already pass from Step 1 changes since feeds call blog.all_posts(). If not, update feed generation. REFACTOR: None needed Files: blogex/test/blogex/feed_test.exs Commit: Verify feeds exclude future-dated posts

Step 4: Verify router respects date filtering — firehose-vyw

Complexity: standard RED: Add tests to blogex/test/blogex/router_test.exs:

  • GET / excludes future-dated posts from index
  • GET /tag/:tag excludes future-dated posts
  • GET /:slug still returns a future-dated post (direct access)
  • GET /feed.xml excludes future-dated posts
  • JSON API index excludes future-dated posts GREEN: Router index/tag routes already use all_posts() and posts_by_tag() which are now filtered. The /:slug route uses get_post() which is now unfiltered. These should pass after Steps 1-2. REFACTOR: None needed Files: blogex/test/blogex/router_test.exs Commit: Verify router respects date filtering on all endpoints

Step 5: Integration tests for Phoenix — firehose-apw

Complexity: standard RED: Add tests to app/test/firehose_web/controllers/blog_test.exs:

  • Blog index does not show future-dated posts
  • Blog show page still returns a future-dated post by slug
  • Tag page excludes future-dated posts (These require a future-dated markdown test fixture or mocking. Since posts are compiled at build time, add a test post file with a far-future date.) GREEN: Should pass from Blogex changes. May need a test fixture markdown file at app/test/support/fixtures/ or app/priv/blog/engineering/2099/01-01-future-post.md REFACTOR: None needed Files: app/test/firehose_web/controllers/blog_test.exs, possibly app/priv/blog/engineering/2099/01-01-future-post.md Commit: Add integration tests for scheduled post filtering in Phoenix

Step 6: Run mix phx.gen.auth and configure — firehose-dhh

Complexity: complex RED: N/A — generator produces its own tests. Verify generated tests pass. GREEN:

  • Run mix phx.gen.auth Accounts User users in the app/ directory
  • Run mix ecto.migrate
  • Verify generated tests pass REFACTOR:
  • Remove registration and login links from generated navigation (they should be hidden from public nav)
  • Ensure no auth links are injected into root.html.heex or app.html.heex layouts Files: Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/controllers/user_*, app/lib/firehose_web/router.ex, layout files Commit: Add phx.gen.auth authentication scaffolding

Step 7: Gate registration to allowed email — firehose-8zg

Complexity: standard RED: Add tests to the generated user_registration_controller_test.exs (or equivalent):

  • Registration succeeds when email matches ALLOWED_REGISTRATION_EMAIL
  • Registration fails with "registration is invite only." when email doesn't match
  • Registration fails with "registration is invite only." when env var is unset GREEN:
  • Add ALLOWED_REGISTRATION_EMAIL to app/config/runtime.exs: config :firehose, :allowed_registration_email, System.get_env("ALLOWED_REGISTRATION_EMAIL")
  • Add validation in the registration changeset or controller that checks Application.get_env(:firehose, :allowed_registration_email) and rejects non-matching emails
  • Set the env var in app/config/test.exs for test suite REFACTOR: None needed Files: app/config/runtime.exs, app/config/test.exs, registration controller/context, registration tests Commit: Gate registration to ALLOWED_REGISTRATION_EMAIL

Step 8: Seed demo user in dev — firehose-pp3

Complexity: trivial RED: N/A (seed script, manual verification) GREEN:

  • In app/priv/repo/seeds.exs: conditionally create demo user when Mix.env() == :dev
  • Email: demo@example.com, password: password123
  • Use the Accounts context generated by phx.gen.auth REFACTOR: None needed Files: app/priv/repo/seeds.exs Commit: Seed demo user in dev environment

Step 9: Add post visibility and days_until_live helpers — firehose-4nq

Complexity: standard RED: Add tests (in a new blogex/test/blogex/post_visibility_test.exs or in the app):

  • Post with published: false has visibility :draft
  • Post with published: true and future date has visibility :scheduled
  • Post with published: true and past/today date has visibility :live
  • Scheduled post returns correct days_until_live count
  • Draft and live posts return nil for days_until_live GREEN:
  • Add Blogex.Post.visibility/1 function: returns :draft, :scheduled, or :live
  • Add Blogex.Post.days_until_live/1: returns integer or nil REFACTOR: None needed Files: blogex/lib/blogex/post.ex, blogex/test/blogex/post_visibility_test.exs (or post_test.exs) Commit: Add post visibility and days_until_live helpers

Step 10: Create LiveView editor dashboard — firehose-4yh

Complexity: complex RED: Add test in app/test/firehose_web/live/editor_dashboard_live_test.exs:

  • Unauthenticated user is redirected to login
  • Authenticated user sees the dashboard
  • Dashboard shows draft posts
  • Dashboard shows scheduled posts with "X days until live"
  • Dashboard shows posts from all blogs in unified timeline
  • Drafts tab is sorted by date descending
  • Scheduled tab is sorted by date ascending (soonest first)
  • Post titles link to the blog show page GREEN:
  • Create app/lib/firehose_web/live/editor_dashboard_live.ex
  • Mount function: load all posts from all blogs via Blogex.Registry, partition into drafts/scheduled using Blogex.Post.visibility/1
  • Template: two tabs, each showing title, date, author, blog name, visibility badge
  • Scheduled tab shows days_until_live
  • Each post links to /blog/:blog_id/:slug
  • Add route /editor/dashboard in router, behind require_authenticated_user plug REFACTOR: Extract tab component if markup is duplicated Files: app/lib/firehose_web/live/editor_dashboard_live.ex, app/lib/firehose_web/router.ex, app/test/firehose_web/live/editor_dashboard_live_test.exs Commit: Add LiveView editor dashboard with drafts and scheduled tabs

Step 11: Show draft/scheduled status banners — firehose-ra3

Complexity: standard RED: Add tests to app/test/firehose_web/controllers/blog_test.exs:

  • Authenticated user viewing a draft post sees "Draft - not published" banner
  • Authenticated user viewing a scheduled post sees "This post is scheduled for {date}" banner
  • Authenticated user viewing a live post sees no banner
  • Unauthenticated user sees no banner on any post GREEN:
  • In blog controller show/2: compute visibility via Blogex.Post.visibility/1, pass to template
  • Check conn.assigns.current_user (from phx.gen.auth) to determine if user is authenticated
  • In show.html.heex: conditionally render banner based on visibility + authenticated REFACTOR: Extract banner into a component if reusable Files: app/lib/firehose_web/controllers/blog_controller.ex, app/lib/firehose_web/controllers/blog_html/show.html.heex, app/test/firehose_web/controllers/blog_test.exs Commit: Show draft/scheduled status banners for authenticated users

Step 12: Add unfiltered post access for dashboard — firehose-ai8

Complexity: standard RED: Add tests:

  • Blogex.Registry.all_posts_unfiltered/0 returns all posts including drafts and future-dated
  • Results are sorted by date descending GREEN:
  • Add all_posts_unfiltered/0 to Blogex.Registry that reads from each blog module's @posts (need to expose an unfiltered_posts/0 function from Blogex.Blog macro)
  • Add unfiltered_posts/0 to Blogex.Blog macro that returns @posts
  • Add unfiltered_posts/0 to FakeBlog that returns the full Agent state posts
  • Dashboard uses all_posts_unfiltered/0 instead of all_posts/0 REFACTOR: None needed Files: blogex/lib/blogex/blog.ex, blogex/lib/blogex/registry.ex, blogex/test/blogex/registry_test.exs, blogex/test/support/fake_blog.ex Commit: Add unfiltered post access for dashboard

Note: Step 12 should be done before Step 10 (dashboard needs unfiltered posts). Reorder during build.

Phase 1 — Scheduled posts (/beads enrich then): 2wc1x31h8vywapw Phase 2 — Authentication (/beads enrich then): dhh8zgpp3 Phase 3 — Dashboard (/beads enrich then): 4nq + ai8 (parallel) → 4yhra3

Pre-PR Quality Gate

  • All tests pass (mix test in both app/ and blogex/)
  • Credo passes (mix credo in app/)
  • No compiler warnings
  • /code-review --changed passes

Risks & Open Questions

  • NimblePublisher compile-time posts: get_post!/1 currently searches all_posts() (filtered). Changing it to search @posts (unfiltered module attribute) works in the real Blog module but FakeBlog needs an equivalent unfiltered source. Mitigated in Step 2 by updating FakeBlog.
  • Test fixtures for future dates: Integration tests in app/ need a markdown file with a far-future date. This file will always be "scheduled" which is fine for testing but will show in dev. Consider using 2099-01-01 to make this obviously a test fixture.
  • phx.gen.auth may modify layouts: The generator inserts nav links for login/register. Step 6 REFACTOR removes these since auth pages should be hidden. Need to verify no regressions in existing layout.
  • Date.utc_today() in compiled module: The all_posts/0 function in Blogex.Blog uses @posts (compile-time) but filters at runtime with Date.utc_today(). This is correct — the date comparison happens at request time, not compile time.