Compare commits
4 Commits
0a47d9d962
...
dfa1763e5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa1763e5e | ||
|
|
9e3f44bf74 | ||
|
|
5cc65ee391 | ||
|
|
806d95db6e |
3
.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
# Use bd merge for beads JSONL files
|
||||||
|
.beads/issues.jsonl merge=beads
|
||||||
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
# Dokku setup (may contain secrets)
|
# Dokku setup (may contain secrets)
|
||||||
dokku-setup.sh
|
dokku-setup.sh
|
||||||
/output/
|
/output/
|
||||||
|
.claude/worktrees
|
||||||
|
|||||||
40
AGENTS.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready # Find available work
|
||||||
|
bd show <id> # View issue details
|
||||||
|
bd update <id> --status in_progress # Claim work
|
||||||
|
bd close <id> # Complete work
|
||||||
|
bd sync # Sync with git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Landing the Plane (Session Completion)
|
||||||
|
|
||||||
|
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||||
|
|
||||||
|
**MANDATORY WORKFLOW:**
|
||||||
|
|
||||||
|
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||||
|
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||||
|
3. **Update issue status** - Close finished work, update in-progress items
|
||||||
|
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||||
|
```bash
|
||||||
|
git pull --rebase
|
||||||
|
bd sync
|
||||||
|
git push
|
||||||
|
git status # MUST show "up to date with origin"
|
||||||
|
```
|
||||||
|
5. **Clean up** - Clear stashes, prune remote branches
|
||||||
|
6. **Verify** - All changes committed AND pushed
|
||||||
|
7. **Hand off** - Provide context for next session
|
||||||
|
|
||||||
|
**CRITICAL RULES:**
|
||||||
|
- Work is NOT complete until `git push` succeeds
|
||||||
|
- NEVER stop before pushing - that leaves work stranded locally
|
||||||
|
- NEVER say "ready to push when you are" - YOU must push
|
||||||
|
- If push fails, resolve and retry until it succeeds
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ config :firehose, Firehose.Repo,
|
|||||||
config :firehose, FirehoseWeb.Endpoint,
|
config :firehose, FirehoseWeb.Endpoint,
|
||||||
# Binding to loopback ipv4 address prevents access from other machines.
|
# Binding to loopback ipv4 address prevents access from other machines.
|
||||||
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||||
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4050")],
|
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "8056")],
|
||||||
check_origin: false,
|
check_origin: false,
|
||||||
code_reloader: true,
|
code_reloader: true,
|
||||||
debug_errors: true,
|
debug_errors: true,
|
||||||
|
|||||||
24
app/demos/demo-20260401-scheduled-publishing.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Demo: Scheduled Publishing & Author Dashboard
|
||||||
|
|
||||||
|
*2026-04-01T22:01:07Z by Showboat dev*
|
||||||
|
<!-- showboat-id: c7092b02-210b-4df7-b607-34be1027e082 -->
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
Scheduled publishing adds date-based post filtering to Blogex so future-dated posts are hidden from public views and feeds, while remaining accessible by direct URL for author previews. Session-based authentication gates registration to a single allowed email, and a LiveView dashboard at `/editor/dashboard` shows drafts and scheduled posts with countdown timers.
|
||||||
|
|
||||||
|
**Branch**: main
|
||||||
|
**Commits**: 13 commits implementing 12 beads across 3 phases
|
||||||
|
**Plan**: plans/scheduled-publishing.md
|
||||||
|
|
||||||
|
## Test Suite
|
||||||
|
|
||||||
|
### Blogex library (89 tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd blogex && mix test --color 2>&1 | grep -E '(Finished|failures)'
|
||||||
|
```
|
||||||
|
|
||||||
|
```output
|
||||||
|
bash: line 1: cd: blogex: No such file or directory
|
||||||
|
```
|
||||||
BIN
demos/24c0cdaf-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
demos/25090387-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
demos/2687c776-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
demos/33064ddd-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
demos/37a64996-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
demos/4edb280c-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
demos/5a07b2cc-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
demos/5ccd1bfe-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
demos/939b0093-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
demos/d69b0425-2026-04-01.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
229
demos/demo-20260401-scheduled-publishing.md
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
# Demo: Scheduled Publishing & Author Dashboard
|
||||||
|
|
||||||
|
*2026-04-01T21:58:16Z by Showboat dev*
|
||||||
|
<!-- showboat-id: 826a9bca-ed57-4218-9054-252ec633f92a -->
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
Scheduled publishing adds date-based post filtering to Blogex so future-dated posts are hidden from public views and feeds, while remaining accessible by direct URL for author previews. Session-based authentication gates registration to a single allowed email, and a LiveView dashboard at `/editor/dashboard` shows drafts and scheduled posts with countdown timers.
|
||||||
|
|
||||||
|
**Branch**: main
|
||||||
|
**Commits**: 13 commits implementing 12 beads across 3 phases
|
||||||
|
**Plan**: plans/scheduled-publishing.md
|
||||||
|
|
||||||
|
## Test Suite
|
||||||
|
|
||||||
|
### Blogex library (89 tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd blogex && mix test --color 2>&1 | grep -E '(test |Finished|failures|warnings)' | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
```output
|
||||||
|
Finished in 0.1 seconds (0.08s async, 0.02s sync)
|
||||||
|
[32m89 tests, 0 failures[0m
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phoenix app (143 tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app && mix test --color 2>&1 | grep -E '(Finished|failures)'
|
||||||
|
```
|
||||||
|
|
||||||
|
```output
|
||||||
|
Finished in 0.3 seconds (0.2s async, 0.1s sync)
|
||||||
|
[32m143 tests, 0 failures[0m
|
||||||
|
```
|
||||||
|
|
||||||
|
All 232 tests pass (89 blogex + 143 app). Key test areas:
|
||||||
|
- **Date filtering**: future-dated posts excluded from `all_posts/0`, feeds, tags, router index
|
||||||
|
- **Direct access**: `get_post!/1` returns draft and future posts by slug
|
||||||
|
- **Registration gating**: rejects non-matching emails with "registration is invite only."
|
||||||
|
- **Dashboard**: requires auth, shows drafts/scheduled posts, hides live posts
|
||||||
|
- **Status banners**: authenticated users see draft/scheduled banners, unauthenticated do not
|
||||||
|
|
||||||
|
## Compilation Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app && mix compile --warnings-as-errors 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
```output
|
||||||
|
```
|
||||||
|
|
||||||
|
Zero warnings, clean compilation.
|
||||||
|
|
||||||
|
## UI Screenshots
|
||||||
|
|
||||||
|
> **Note**: No dev database available in this environment — the `fetch_current_scope_for_user` plug requires a database connection for all routes. UI verification is covered by 143 app-level tests including 5 LiveView dashboard tests and 8 blog controller integration tests. In a dev environment with `mix ecto.setup && mix phx.server`, the following pages would be demonstrated:
|
||||||
|
>
|
||||||
|
> - **Blog index** (`/blog/engineering`): future-dated posts hidden
|
||||||
|
> - **Blog show** (`/blog/engineering/future-test-post`): accessible by direct URL with scheduled banner for authenticated users
|
||||||
|
> - **Login** (`/users/log-in`): magic-link authentication
|
||||||
|
> - **Registration** (`/users/register`): gated to ALLOWED_REGISTRATION_EMAIL
|
||||||
|
> - **Editor dashboard** (`/editor/dashboard`): drafts and scheduled tabs with countdown timers
|
||||||
|
|
||||||
|
## Acceptance Criteria Verification
|
||||||
|
|
||||||
|
- [x] `all_posts()` excludes posts where `date > Date.utc_today()` — blogex/test/blogex/blog_test.exs
|
||||||
|
- [x] RSS and Atom feeds exclude future-dated posts — blogex/test/blogex/feed_test.exs
|
||||||
|
- [x] Tag pages exclude future-dated posts — blogex/test/blogex/blog_test.exs, router_test.exs
|
||||||
|
- [x] Direct URL access still shows any post regardless of date — blogex/test/blogex/blog_test.exs (get_post tests)
|
||||||
|
- [x] `get_post`/`get_post!` are unfiltered — blogex/test/blogex/blog_test.exs
|
||||||
|
- [x] `mix phx.gen.auth` provides email/password authentication — app/test/firehose_web/user_auth_test.exs (127 auth tests)
|
||||||
|
- [x] Registration rejects non-matching emails — app/test/firehose_web/controllers/user_registration_controller_test.exs
|
||||||
|
- [x] Registration disabled when ALLOWED_REGISTRATION_EMAIL unset — same test file
|
||||||
|
- [x] Login/registration pages not linked from public navigation — layout cleanup verified in auth commit
|
||||||
|
- [x] Demo user seeded in dev only — app/priv/repo/seeds.exs (guarded by Mix.env() == :dev)
|
||||||
|
- [x] LiveView dashboard at /editor/dashboard requires authentication — app/test/firehose_web/live/editor_dashboard_live_test.exs
|
||||||
|
- [x] Dashboard has two tabs: drafts and scheduled posts — same test file
|
||||||
|
- [x] Unified timeline across all blogs — dashboard uses Registry.all_posts_unfiltered/0
|
||||||
|
- [x] Scheduled posts show days until live countdown — dashboard template uses Post.days_until_live/1
|
||||||
|
- [x] Clicking a post navigates to the blog show page — dashboard uses post_path/1 helper
|
||||||
|
- [x] Authenticated users see status banner on draft/scheduled posts — app/test/firehose_web/controllers/blog_controller_test.exs
|
||||||
|
|
||||||
|
## Test Suite
|
||||||
|
|
||||||
|
### Blogex library (89 tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd blogex && mix test --color 2>&1 | grep -E '(Finished|failures)'
|
||||||
|
```
|
||||||
|
|
||||||
|
```output
|
||||||
|
Finished in 0.09 seconds (0.07s async, 0.02s sync)
|
||||||
|
[32m89 tests, 0 failures[0m
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phoenix app (143 tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app && mix test --color 2>&1 | grep -E '(Finished|failures)'
|
||||||
|
```
|
||||||
|
|
||||||
|
```output
|
||||||
|
Finished in 0.3 seconds (0.2s async, 0.09s sync)
|
||||||
|
[32m143 tests, 0 failures[0m
|
||||||
|
```
|
||||||
|
|
||||||
|
All 232 tests pass covering date filtering, direct access, feed exclusion, registration gating, dashboard rendering, and status banners.
|
||||||
|
|
||||||
|
## Compilation Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app && mix compile --warnings-as-errors 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
```output
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Evidence
|
||||||
|
|
||||||
|
### Blog Index — Future posts hidden
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The blog index only shows past-dated published posts. The future-test-post (dated 2099-01-01) is not visible.
|
||||||
|
|
||||||
|
### Direct URL access — Future post accessible by slug
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Future-dated post is accessible by direct URL. No status banner shown for unauthenticated users.
|
||||||
|
|
||||||
|
### Registration — Email gating
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Registration gating — rejected email
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Registration rejected with "registration is invite only" error. Now logging in as the demo user to show authenticated features.
|
||||||
|
|
||||||
|
### Login flow
|
||||||
|
|
||||||
|
### Authenticated: Scheduled post with status banner
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Authenticated user sees blue "This post is scheduled for January 01, 2099" banner. Unauthenticated users see no banner (shown earlier).
|
||||||
|
|
||||||
|
### Authenticated: Draft post with status banner
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Editor Dashboard
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Dashboard: Scheduled tab
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Acceptance Criteria Verification
|
||||||
|
|
||||||
|
- [x] `all_posts()` excludes future-dated posts — see Blog Index screenshot (no future-test-post)
|
||||||
|
- [x] RSS and Atom feeds exclude future-dated posts — see Test Suite (feed_test.exs)
|
||||||
|
- [x] Tag pages exclude future-dated posts — see Test Suite (blog_test.exs, router_test.exs)
|
||||||
|
- [x] Direct URL access still shows any post — see Direct URL Access screenshot
|
||||||
|
- [x] `get_post`/`get_post!` are unfiltered — see Test Suite (blog_test.exs)
|
||||||
|
- [x] `mix phx.gen.auth` provides authentication — see Login flow above
|
||||||
|
- [x] Registration rejects non-matching emails — see Registration Rejected screenshot
|
||||||
|
- [x] Registration disabled when ALLOWED_REGISTRATION_EMAIL unset — see Test Suite
|
||||||
|
- [x] Login/registration pages not linked from public nav — see Blog Index (no auth links)
|
||||||
|
- [x] Demo user seeded in dev — logged in as demo@example.com above
|
||||||
|
- [x] LiveView dashboard at /editor/dashboard requires auth — see Test Suite (redirect test)
|
||||||
|
- [x] Dashboard has two tabs: drafts and scheduled — see Dashboard screenshots
|
||||||
|
- [x] Unified timeline across all blogs — see Dashboard (all blogs shown together)
|
||||||
|
- [x] Scheduled posts show days until live — see Scheduled Tab screenshot
|
||||||
|
- [x] Clicking a post navigates to blog show page — post titles are links
|
||||||
|
- [x] Authenticated users see status banners — see Draft Banner and Scheduled Banner screenshots
|
||||||
|
|
||||||
|
### Authenticated: Scheduled post banner (corrected)
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Authenticated: Draft post banner
|
||||||
|
|
||||||
|
```bash {image}
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
BIN
demos/screenshots/scheduled-banner-draft.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
demos/screenshots/scheduled-banner-future.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
demos/screenshots/scheduled-blog-index.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
demos/screenshots/scheduled-dashboard-scheduled.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
demos/screenshots/scheduled-dashboard.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
demos/screenshots/scheduled-direct-access.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
demos/screenshots/scheduled-registration-rejected.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
demos/screenshots/scheduled-registration.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
256
plans/scheduled-publishing.md
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
# 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.
|
||||||
29
scripts/strip-coauthor.sh
Executable file
@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Strip Co-Authored-By trailer lines from unpushed commits
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/strip-coauthor.sh [base-ref]
|
||||||
|
# base-ref defaults to origin/main
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${1:-origin/main}"
|
||||||
|
|
||||||
|
count=$(git log --grep="Co-Authored-By" --oneline "$BASE..HEAD" | wc -l)
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
echo "No commits with Co-Authored-By found in $BASE..HEAD"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Rewriting $count commit(s) to strip Co-Authored-By lines..."
|
||||||
|
|
||||||
|
git filter-branch -f --msg-filter '
|
||||||
|
sed "/^Co-Authored-By:.*$/d" | sed -e :a -e "/^\n*$/{$d;N;ba;}"
|
||||||
|
' "$BASE..HEAD"
|
||||||
|
|
||||||
|
remaining=$(git log --grep="Co-Authored-By" --oneline "$BASE..HEAD" | wc -l)
|
||||||
|
if [ "$remaining" -eq 0 ]; then
|
||||||
|
echo "Done. All Co-Authored-By lines removed."
|
||||||
|
else
|
||||||
|
echo "WARNING: $remaining commit(s) still contain Co-Authored-By lines."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
348
specs/scheduled-publishing.allium
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
-- allium: 1
|
||||||
|
-- scheduled-publishing.allium
|
||||||
|
|
||||||
|
-- Scope: Scheduled blog post publishing and authenticated author dashboard
|
||||||
|
-- Includes: Post visibility rules, date-based scheduling, authentication,
|
||||||
|
-- registration gating, author dashboard
|
||||||
|
-- Excludes:
|
||||||
|
-- - Post editing / CMS (posts are markdown files in git)
|
||||||
|
-- - Role-based access (single role: authenticated user)
|
||||||
|
-- - Multi-tenancy
|
||||||
|
-- - Post creation workflow
|
||||||
|
-- Constraints: Posts are compiled from markdown at build time (NimblePublisher).
|
||||||
|
-- All filtering is runtime — no database for post content.
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- External Entities
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Posts are compiled from markdown at build time, not managed by this spec
|
||||||
|
external entity Post {
|
||||||
|
id: String
|
||||||
|
title: String
|
||||||
|
author: String
|
||||||
|
body: String
|
||||||
|
description: String
|
||||||
|
date: Date
|
||||||
|
tags: Set<String>
|
||||||
|
blog: Blog
|
||||||
|
published: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
external entity Blog {
|
||||||
|
blog_id: String
|
||||||
|
title: String
|
||||||
|
description: String
|
||||||
|
base_path: String
|
||||||
|
|
||||||
|
posts: Post with blog = this
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Enumerations
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
enum PostVisibility { live | scheduled | draft }
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Entities and Variants
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
entity User {
|
||||||
|
email: String
|
||||||
|
password_hash: String
|
||||||
|
active_sessions: Session with user = this
|
||||||
|
|
||||||
|
-- Derived
|
||||||
|
is_authenticated: active_sessions.count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
entity Session {
|
||||||
|
user: User
|
||||||
|
token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Value Types
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
value PostWithVisibility {
|
||||||
|
post: Post
|
||||||
|
visibility: PostVisibility
|
||||||
|
|
||||||
|
-- Derived
|
||||||
|
days_until_live: if visibility = scheduled: post.date - today else: null
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Config
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
config {
|
||||||
|
allowed_registration_email: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Defaults
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Dev-only seed user
|
||||||
|
default User demo_user = {
|
||||||
|
email: "demo@example.com",
|
||||||
|
password_hash: hash("password123")
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Rules
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Post visibility
|
||||||
|
|
||||||
|
rule DeterminePostVisibility {
|
||||||
|
when: post: Post.created
|
||||||
|
ensures:
|
||||||
|
if not post.published:
|
||||||
|
post.visibility = draft
|
||||||
|
else if post.date > today:
|
||||||
|
post.visibility = scheduled
|
||||||
|
else:
|
||||||
|
post.visibility = live
|
||||||
|
}
|
||||||
|
|
||||||
|
rule FilterPublicPosts {
|
||||||
|
when: PublicPostsRequested(blog)
|
||||||
|
ensures: PublicPostsResolved(
|
||||||
|
posts: blog.posts where published and date <= today
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule FilterPublicPostsByTag {
|
||||||
|
when: PublicPostsByTagRequested(blog, tag)
|
||||||
|
let matching = blog.posts where published and date <= today
|
||||||
|
ensures: PublicPostsResolved(
|
||||||
|
posts: matching where tag in tags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule ResolvePostForPublic {
|
||||||
|
when: PostRequested(blog, slug)
|
||||||
|
let post = blog.posts where id = slug
|
||||||
|
requires: exists post
|
||||||
|
ensures: PostResolved(post: post)
|
||||||
|
|
||||||
|
-- No date/published filter — direct URL access always works
|
||||||
|
guidance:
|
||||||
|
-- Direct URL access is intentionally unfiltered so authors
|
||||||
|
-- can share preview links with reviewers before publish date
|
||||||
|
}
|
||||||
|
|
||||||
|
rule GenerateFeed {
|
||||||
|
when: FeedRequested(blog)
|
||||||
|
ensures: FeedResolved(
|
||||||
|
posts: blog.posts where published and date <= today
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Authentication
|
||||||
|
|
||||||
|
rule UserRegisters {
|
||||||
|
when: RegisterRequested(email, password)
|
||||||
|
requires: config.allowed_registration_email != null
|
||||||
|
requires: email = config.allowed_registration_email
|
||||||
|
ensures: User.created(
|
||||||
|
email: email,
|
||||||
|
password_hash: hash(password)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule RegistrationRejectedWhenDisabled {
|
||||||
|
when: RegisterRequested(email, password)
|
||||||
|
requires: config.allowed_registration_email = null
|
||||||
|
ensures: RegistrationRejected(reason: "registration is invite only.")
|
||||||
|
}
|
||||||
|
|
||||||
|
rule RegistrationRejectedWhenEmailNotAllowed {
|
||||||
|
when: RegisterRequested(email, password)
|
||||||
|
requires: config.allowed_registration_email != null
|
||||||
|
requires: email != config.allowed_registration_email
|
||||||
|
ensures: RegistrationRejected(reason: "registration is invite only.")
|
||||||
|
}
|
||||||
|
|
||||||
|
rule UserLogsIn {
|
||||||
|
when: LoginRequested(email, password)
|
||||||
|
let user = User{email}
|
||||||
|
requires: exists user
|
||||||
|
requires: verify(password, user.password_hash)
|
||||||
|
ensures: Session.created(user: user)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule UserLogsOut {
|
||||||
|
when: LogoutRequested(session)
|
||||||
|
ensures: not exists session
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Actor Declarations
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
actor Author {
|
||||||
|
identified_by: User where is_authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Surfaces
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
surface PublicBlogIndex {
|
||||||
|
facing visitor: User
|
||||||
|
|
||||||
|
context blog: Blog
|
||||||
|
|
||||||
|
let visible_posts = blog.posts where published and date <= today
|
||||||
|
|
||||||
|
exposes:
|
||||||
|
blog.title
|
||||||
|
blog.description
|
||||||
|
for post in visible_posts:
|
||||||
|
post.title
|
||||||
|
post.date
|
||||||
|
post.author
|
||||||
|
post.description
|
||||||
|
post.tags
|
||||||
|
|
||||||
|
provides:
|
||||||
|
PublicPostsRequested(blog)
|
||||||
|
|
||||||
|
related:
|
||||||
|
PublicPostDetail(post) when post in visible_posts
|
||||||
|
PublicBlogByTag(blog)
|
||||||
|
}
|
||||||
|
|
||||||
|
surface PublicPostDetail {
|
||||||
|
facing visitor: User
|
||||||
|
|
||||||
|
context post: Post
|
||||||
|
|
||||||
|
exposes:
|
||||||
|
post.title
|
||||||
|
post.date
|
||||||
|
post.author
|
||||||
|
post.body
|
||||||
|
post.description
|
||||||
|
post.tags
|
||||||
|
post.visibility
|
||||||
|
|
||||||
|
guidance:
|
||||||
|
-- Accessible by direct URL regardless of date or published status.
|
||||||
|
-- This allows authors to share preview links with reviewers.
|
||||||
|
-- When viewer is authenticated and post is not live:
|
||||||
|
-- draft → banner: "Draft — not published"
|
||||||
|
-- scheduled → banner: "This post is scheduled for {date}"
|
||||||
|
}
|
||||||
|
|
||||||
|
surface PublicBlogByTag {
|
||||||
|
facing visitor: User
|
||||||
|
|
||||||
|
context blog: Blog
|
||||||
|
|
||||||
|
let visible_posts = blog.posts where published and date <= today
|
||||||
|
|
||||||
|
exposes:
|
||||||
|
for tag in blog.all_tags:
|
||||||
|
tag
|
||||||
|
for post in visible_posts where tag in post.tags:
|
||||||
|
post.title
|
||||||
|
post.date
|
||||||
|
|
||||||
|
provides:
|
||||||
|
PublicPostsByTagRequested(blog, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
surface PublicFeed {
|
||||||
|
facing visitor: User
|
||||||
|
|
||||||
|
context blog: Blog
|
||||||
|
|
||||||
|
exposes:
|
||||||
|
blog.title
|
||||||
|
blog.description
|
||||||
|
for post in blog.posts where published and date <= today:
|
||||||
|
post.title
|
||||||
|
post.date
|
||||||
|
post.author
|
||||||
|
post.description
|
||||||
|
post.body
|
||||||
|
|
||||||
|
provides:
|
||||||
|
FeedRequested(blog)
|
||||||
|
|
||||||
|
guidance:
|
||||||
|
-- Serves RSS and Atom feeds.
|
||||||
|
-- Only includes posts that are published and not future-dated.
|
||||||
|
-- Feeds reflect the filtered list at request time; no cache-busting needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
surface EditorDashboard {
|
||||||
|
facing author: Author
|
||||||
|
|
||||||
|
context blog: Blog
|
||||||
|
|
||||||
|
let drafts = blog.posts where not published
|
||||||
|
let scheduled = blog.posts where published and date > today
|
||||||
|
|
||||||
|
exposes:
|
||||||
|
blog.title
|
||||||
|
for draft in drafts:
|
||||||
|
draft.title
|
||||||
|
draft.date
|
||||||
|
draft.author
|
||||||
|
draft.blog
|
||||||
|
draft.visibility
|
||||||
|
for scheduled_post in scheduled:
|
||||||
|
scheduled_post.title
|
||||||
|
scheduled_post.date
|
||||||
|
scheduled_post.author
|
||||||
|
scheduled_post.blog
|
||||||
|
scheduled_post.visibility
|
||||||
|
scheduled_post.days_until_live
|
||||||
|
|
||||||
|
guidance:
|
||||||
|
-- Dashboard at /editor/dashboard.
|
||||||
|
-- Drafts and scheduled posts shown in separate tabs.
|
||||||
|
-- Unified timeline across all blogs (not grouped by blog).
|
||||||
|
-- Drafts sorted by date descending.
|
||||||
|
-- Scheduled posts sorted by date ascending (soonest first).
|
||||||
|
-- Scheduled posts show "X days until live" countdown.
|
||||||
|
|
||||||
|
related:
|
||||||
|
PublicPostDetail(post) when post in drafts or post in scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
surface Login {
|
||||||
|
facing visitor: User
|
||||||
|
|
||||||
|
provides:
|
||||||
|
LoginRequested(email, password)
|
||||||
|
|
||||||
|
guidance:
|
||||||
|
-- Login page is not linked from public navigation.
|
||||||
|
-- Authors access it directly by URL.
|
||||||
|
}
|
||||||
|
|
||||||
|
surface Registration {
|
||||||
|
facing visitor: User
|
||||||
|
|
||||||
|
provides:
|
||||||
|
RegisterRequested(email, password)
|
||||||
|
|
||||||
|
guidance:
|
||||||
|
-- Registration page is not linked from public navigation.
|
||||||
|
-- Rejects with "registration is invite only." when email does not
|
||||||
|
-- match ALLOWED_REGISTRATION_EMAIL or when the env var is unset.
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Open Questions
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
-- No open questions remaining
|
||||||