# 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/0` — `firehose-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. ## 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 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.