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 wheredate > 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.authprovides email/password authentication- Registration rejects non-matching emails with "registration is invite only."
- Registration is disabled entirely when
ALLOWED_REGISTRATION_EMAILis unset - Login/registration pages are not linked from public navigation
- Demo user seeded in dev only (
demo@example.com/password123) - LiveView dashboard at
/editor/dashboardrequires 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/0 — firehose-2wc
Complexity: standard
RED: Add tests to blogex/test/blogex/blog_test.exs:
all_posts/0excludes future-dated posts (add a post with date~D[2099-01-01],published: trueto FakeBlog — it should not appear)all_posts/0includes past-dated published posts (existing tests cover this)all_posts/0includes today-dated published postsposts_by_tag/1excludes future-dated postsrecent_posts/1excludes future-dated postsall_tags/0excludes tags only on future-dated posts GREEN:- In
blogex/lib/blogex/blog.exall_posts/0: adddate <= Date.utc_today()filter after the existingpublishedfilter - In
blogex/test/support/fake_blog.exall_posts/0: add same date filter REFACTOR: Extract the filtering predicate into a shared helper if bothBlogandFakeBlogduplicate the logic Files:blogex/lib/blogex/blog.ex,blogex/test/blogex/blog_test.exs,blogex/test/support/fake_blog.ex,blogex/test/support/setup.exCommit: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!/1returns a future-dated published post by slugget_post/1returns a future-dated published post by slugget_post!/1returns a draft post by slug (currently raises — this is a behaviour change)get_post/1returns a draft post by slug (currently returns nil — behaviour change) GREEN:- In
blogex/lib/blogex/blog.ex: changeget_post!/1andget_post/1to search@posts(the unfiltered compile-time list) instead ofall_posts() - In
blogex/test/support/fake_blog.ex: changeget_post!/1andget_post/1to search from the full unfiltered posts list (Agent state:posts) instead ofall_posts()REFACTOR: Update existing test that expectsget_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.exCommit: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 callblog.all_posts(). If not, update feed generation. REFACTOR: None needed Files:blogex/test/blogex/feed_test.exsCommit: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 indexGET /tag/:tagexcludes future-dated postsGET /:slugstill returns a future-dated post (direct access)GET /feed.xmlexcludes future-dated posts- JSON API index excludes future-dated posts
GREEN: Router index/tag routes already use
all_posts()andposts_by_tag()which are now filtered. The/:slugroute usesget_post()which is now unfiltered. These should pass after Steps 1-2. REFACTOR: None needed Files:blogex/test/blogex/router_test.exsCommit: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/orapp/priv/blog/engineering/2099/01-01-future-post.mdREFACTOR: None needed Files:app/test/firehose_web/controllers/blog_test.exs, possiblyapp/priv/blog/engineering/2099/01-01-future-post.mdCommit: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 usersin theapp/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.heexorapp.html.heexlayouts Files: Generated files inapp/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_EMAILtoapp/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.exsfor 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 whenMix.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.exsCommit: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: falsehas visibility:draft - Post with
published: trueand future date has visibility:scheduled - Post with
published: trueand past/today date has visibility:live - Scheduled post returns correct
days_until_livecount - Draft and live posts return
nilfordays_until_liveGREEN: - Add
Blogex.Post.visibility/1function: 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(orpost_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 usingBlogex.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/dashboardin router, behindrequire_authenticated_userplug 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.exsCommit: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 viaBlogex.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.exsCommit: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/0returns all posts including drafts and future-dated- Results are sorted by date descending GREEN:
- Add
all_posts_unfiltered/0toBlogex.Registrythat reads from each blog module's@posts(need to expose anunfiltered_posts/0function fromBlogex.Blogmacro) - Add
unfiltered_posts/0toBlogex.Blogmacro that returns@posts - Add
unfiltered_posts/0toFakeBlogthat returns the full Agent state posts - Dashboard uses
all_posts_unfiltered/0instead ofall_posts/0REFACTOR: None needed Files:blogex/lib/blogex/blog.ex,blogex/lib/blogex/registry.ex,blogex/test/blogex/registry_test.exs,blogex/test/support/fake_blog.exCommit:Add unfiltered post access for dashboard
Note: Step 12 should be done before Step 10 (dashboard needs unfiltered posts). Reorder during build.
Recommended Build Order
Phase 1 — Scheduled posts (/beads enrich then): 2wc → 1x3 → 1h8 → vyw → apw
Phase 2 — Authentication (/beads enrich then): dhh → 8zg → pp3
Phase 3 — Dashboard (/beads enrich then): 4nq + ai8 (parallel) → 4yh → ra3
Pre-PR Quality Gate
- All tests pass (
mix testin bothapp/andblogex/) - Credo passes (
mix credoinapp/) - No compiler warnings
/code-review --changedpasses
Risks & Open Questions
- NimblePublisher compile-time posts:
get_post!/1currently searchesall_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 using2099-01-01to 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: Theall_posts/0function inBlogex.Bloguses@posts(compile-time) but filters at runtime withDate.utc_today(). This is correct — the date comparison happens at request time, not compile time.