Add Dashboard nav link for authenticated users
- Add conditional "Dashboard" link in navbar that shows when a user is logged in - Tests verify the link is hidden for unauthenticated users and visible when authenticated - Investigation reports for Q1 (drafts/scheduled), Q2 (RSS), Q3 (email subs)
This commit is contained in:
parent
f6229ebf77
commit
fe156ef81e
@ -23,6 +23,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/contact" class="btn btn-ghost btn-sm">Contact</a>
|
<a href="/contact" class="btn btn-ghost btn-sm">Contact</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li :if={@current_scope && @current_scope.user}>
|
||||||
|
<a href="/editor/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.theme_toggle />
|
<.theme_toggle />
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,9 +1,32 @@
|
|||||||
defmodule FirehoseWeb.PageControllerTest do
|
defmodule FirehoseWeb.PageControllerTest do
|
||||||
use FirehoseWeb.ConnCase
|
use FirehoseWeb.ConnCase
|
||||||
|
|
||||||
test "GET /", %{conn: conn} do
|
describe "GET /" do
|
||||||
|
test "renders the homepage", %{conn: conn} do
|
||||||
body = conn |> get(~p"/") |> html_response(200)
|
body = conn |> get(~p"/") |> html_response(200)
|
||||||
assert body =~ "Drinking from the firehose"
|
assert body =~ "Drinking from the firehose"
|
||||||
assert body =~ "Willem van den Ende"
|
assert body =~ "Willem van den Ende"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "shows navigation links", %{conn: conn} do
|
||||||
|
body = conn |> get(~p"/") |> html_response(200)
|
||||||
|
assert body =~ ~s|/blog/engineering|
|
||||||
|
assert body =~ ~s|/blog/releases|
|
||||||
|
assert body =~ ~s|/contact|
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not show dashboard link when unauthenticated", %{conn: conn} do
|
||||||
|
body = conn |> get(~p"/") |> html_response(200)
|
||||||
|
refute body =~ ~s|/editor/dashboard|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET / when authenticated" do
|
||||||
|
setup :register_and_log_in_user
|
||||||
|
|
||||||
|
test "shows dashboard link in navigation", %{conn: conn} do
|
||||||
|
body = conn |> get(~p"/") |> html_response(200)
|
||||||
|
assert body =~ ~s|/editor/dashboard|
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
77
investigation/context.md
Normal file
77
investigation/context.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Codebase Context for Investigation
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
- **app/** - Phoenix LiveView application with blogs, user auth, editor dashboard
|
||||||
|
- **blogex/** - Multi-blog engine library (Blogex) with NimblePublisher-based markdown posts
|
||||||
|
|
||||||
|
## Key Modules
|
||||||
|
|
||||||
|
### Blog Config (app/lib/firehose/blogs/)
|
||||||
|
- `Firehose.EngineeringBlog` - blog_id: :engineering, base_path: "/blog/engineering"
|
||||||
|
- `Firehose.ReleaseNotes` - blog_id: :release_notes, base_path: "/blog/releases"
|
||||||
|
|
||||||
|
### Blog Post System (blogex/lib/blogex/)
|
||||||
|
- `Blogex.Post` - struct with fields: id, title, author, body, description, date, blog, image, tags, published
|
||||||
|
- `Post.visibility(post)` returns :draft (published=false), :scheduled (future date), :live
|
||||||
|
- `Post.days_until_live(post)` returns days until scheduled post goes live
|
||||||
|
- Posts are compiled at build-time from markdown files in priv/blog/
|
||||||
|
|
||||||
|
### Blog Module (blogex/lib/blogex/blog.ex)
|
||||||
|
- `Blogex.Blog` - macro that uses NimblePublisher
|
||||||
|
- `all_posts()` - returns published, non-future-dated posts (drafts excluded in prod)
|
||||||
|
- `unfiltered_posts()` - returns ALL posts including drafts and future-dated
|
||||||
|
- `recent_posts(n)` - returns n most recent published posts
|
||||||
|
- `paginate(page, per_page)` - pagination support
|
||||||
|
|
||||||
|
### Registry (blogex/lib/blogex/registry.ex)
|
||||||
|
- `Blogex.Registry.all_posts()` - all published posts across blogs
|
||||||
|
- `Blogex.Registry.all_posts_unfiltered()` - all posts including drafts/scheduled
|
||||||
|
- `Blogex.Registry.get_blog!(blog_id)` - get blog module by atom
|
||||||
|
|
||||||
|
### Router (app/lib/firehose_web/router.ex)
|
||||||
|
- GET / - PageController.home (homepage with recent posts)
|
||||||
|
- GET /blog/:blog_id - BlogController.index (blog listing)
|
||||||
|
- GET /blog/:blog_id/:slug - BlogController.show (single post)
|
||||||
|
- GET /blog/:blog_id/tag/:tag - BlogController.tag (posts by tag)
|
||||||
|
- GET /api/blog/engineering - Blogex.Router (serves HTML+JSON+RSS)
|
||||||
|
- GET /api/blog/releases - Blogex.Router (serves HTML+JSON+RSS)
|
||||||
|
- LiveView: GET /editor/dashboard - EditorDashboardLive (authenticated)
|
||||||
|
- LiveView: GET /microprints - MicroprintsLive
|
||||||
|
|
||||||
|
### Blogex Router (blogex/lib/blogex/router.ex)
|
||||||
|
- Serves at /api/blog/{engineering,releases}
|
||||||
|
- GET /feed.xml - RSS 2.0 feed
|
||||||
|
- GET /atom.xml - Atom feed
|
||||||
|
- These are mounted under /api/blog/* so feeds are at /api/blog/engineering/feed.xml
|
||||||
|
|
||||||
|
### Blog Controller (app/lib/firehose_web/controllers/blog_controller.ex)
|
||||||
|
- Renders blog index, show, and tag pages using Phoenix templates with Blogex.Components
|
||||||
|
- Uses the Phoenix layout (app.html.heex) with full UI
|
||||||
|
|
||||||
|
### Editor Dashboard (app/lib/firehose_web/live/editor_dashboard_live.ex)
|
||||||
|
- Authenticated LiveView at /editor/dashboard
|
||||||
|
- Shows Drafts and Scheduled posts tabs
|
||||||
|
- Uses Blogex.Registry.all_posts_unfiltered()
|
||||||
|
- Has a tab switcher between drafts and scheduled
|
||||||
|
- Each post shows title, author, date, status
|
||||||
|
|
||||||
|
### Layout/UI (app/lib/firehose_web/components/layouts/app.html.heex)
|
||||||
|
- Navbar with links to: Home, Blog, Releases, QWAN (external), Contact, theme toggle
|
||||||
|
- No RSS icon/link visible in nav
|
||||||
|
- No link to Editor Dashboard in nav (only accessible via direct URL)
|
||||||
|
|
||||||
|
### Homepage (app/lib/firehose_web/controllers/page_html/home.html.heex)
|
||||||
|
- Shows intro text and recent posts grid (6 most recent across both blogs)
|
||||||
|
|
||||||
|
### Email/Mailer
|
||||||
|
- `Firehose.Mailer` uses Swoosh with gen_smtp adapter
|
||||||
|
- `Firehose.Accounts` has user registration, login, password change
|
||||||
|
- `Firehose.Accounts.UserNotifier` for email notifications
|
||||||
|
- No subscription/newsletter functionality exists
|
||||||
|
|
||||||
|
### Mix Dependencies
|
||||||
|
- phoenix_live_view ~> 1.1.0
|
||||||
|
- swoosh ~> 1.16 (email library)
|
||||||
|
- gen_smtp ~> 1.0 (SMTP adapter)
|
||||||
|
- ecto_sql ~> 3.13, postgrex
|
||||||
|
- blogex (path dependency, local)
|
||||||
153
investigation/question-1-drafts-scheduled-list.md
Normal file
153
investigation/question-1-drafts-scheduled-list.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
# Question 1: "Is there a way to see all drafts and scheduled posts in a list?"
|
||||||
|
|
||||||
|
## Answer Summary
|
||||||
|
|
||||||
|
Yes — an editor dashboard exists at `/editor/dashboard` showing all drafts and scheduled posts, but it is **not linked from the navigation**. Only users who know the URL can access it.
|
||||||
|
|
||||||
|
Public routes (index, homepage, tag pages) never show drafts/scheduled in production. However, authenticated users can view individual draft/scheduled posts **by direct URL** if they know the slug — the show template detects visibility and shows a status banner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Existing Views/Routes for Drafts & Scheduled Posts
|
||||||
|
|
||||||
|
### Editor Dashboard (LiveView) — `/editor/dashboard`
|
||||||
|
|
||||||
|
- **File:** `app/lib/firehose_web/live/editor_dashboard_live.ex`
|
||||||
|
- **Route:** `live "/editor/dashboard", EditorDashboardLive` (router.ex line 87)
|
||||||
|
- **Auth:** Requires authenticated user (`:require_authenticated_user` pipe, line 84)
|
||||||
|
- **Data source:** `Blogex.Registry.all_posts_unfiltered()` — returns ALL posts regardless of published/date
|
||||||
|
- **Features:**
|
||||||
|
- Two tabs: Drafts (published=false) and Scheduled (future date)
|
||||||
|
- Count badges per tab
|
||||||
|
- Shows title, author, date, status badge, and days-until-live for scheduled posts
|
||||||
|
- Links each post to its public URL
|
||||||
|
|
||||||
|
### BlogController.show — individual draft/scheduled post viewing
|
||||||
|
|
||||||
|
- **File:** `app/lib/firehose_web/controllers/blog_controller.ex`
|
||||||
|
- **Route:** `GET /blog/:blog_id/:slug` (router.ex line 38)
|
||||||
|
- **Auth:** No auth middleware — but show template only shows status banners when authenticated
|
||||||
|
- **Data source:** `blog.get_post!()` (line 22) uses `unfiltered_posts()` — returns ANY post by slug, including drafts/scheduled
|
||||||
|
- **Show template** (`app/lib/firehose_web/controllers/blog_html/show.html.heex`, lines 6-22):
|
||||||
|
- Shows a draft banner (amber) only when `@authenticated` and visibility is `:draft`
|
||||||
|
- Shows a scheduled banner (blue) only when `@authenticated` and visibility is `:scheduled`
|
||||||
|
|
||||||
|
### BlogController.index / tag — do NOT show drafts/scheduled
|
||||||
|
|
||||||
|
- Uses `blog.paginate()` / `blog.posts_by_tag()` which call `all_posts()` — filtered in production
|
||||||
|
- See section 3 for details
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Is the Editor Dashboard linked from the main navigation?
|
||||||
|
|
||||||
|
**No.** The navbar in `app/lib/firehose_web/components/layouts/app.html.heex` (lines 1-23) contains:
|
||||||
|
|
||||||
|
| Link | Target |
|
||||||
|
|------|--------|
|
||||||
|
| Blog | `/blog/engineering` |
|
||||||
|
| Releases | `/blog/releases` |
|
||||||
|
| QWAN | `https://qwan.eu` (external) |
|
||||||
|
| Contact | `/contact` |
|
||||||
|
| Theme toggle | inline JS |
|
||||||
|
|
||||||
|
There are **no user/authentication-related links at all** — no login, logout, settings, or dashboard link. The layout does not reference `@current_scope` or any authenticated-user assigns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Do any public routes show draft or scheduled posts?
|
||||||
|
|
||||||
|
| Route | Uses | Shows drafts/scheduled? |
|
||||||
|
|-------|------|------------------------|
|
||||||
|
| `GET /` (homepage) | `recent_posts(3)` → `all_posts()` | ❌ No (filtered) |
|
||||||
|
| `GET /blog/:blog_id` (index) | `paginate()` → `all_posts()` | ❌ No (filtered) |
|
||||||
|
| `GET /blog/:blog_id/tag/:tag` | `posts_by_tag()` → `all_posts()` | ❌ No (filtered) |
|
||||||
|
| `GET /blog/:blog_id/:slug` (show) | `get_post!()` → `unfiltered_posts()` | ✅ Yes — any authenticated user can view via direct URL, with status banner |
|
||||||
|
|
||||||
|
### Filtering logic (in `Blogex.Blog`, `app/lib/firehose/blog/blog.ex` lines 85-90):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def all_posts do
|
||||||
|
today = Date.utc_today()
|
||||||
|
if Blogex.show_drafts?() do
|
||||||
|
Enum.filter(@posts, &(not Date.after?(&1.date, today)))
|
||||||
|
else
|
||||||
|
Enum.filter(@posts, &(&1.published and not Date.after?(&1.date, today)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Dev mode** (`config/config.exs` line 78): `show_drafts: true` — includes drafts (published=false) but excludes future-dated (scheduled)
|
||||||
|
- **Prod mode** (`config/prod.exs` line 17): `show_drafts: false` — only live posts visible
|
||||||
|
|
||||||
|
**Important gap:** `all_posts()` in dev includes drafts but NOT scheduled. The editor dashboard (`all_posts_unfiltered()`) handles both.
|
||||||
|
|
||||||
|
### Show template control flow (show.html.heex lines 4-22):
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<%= if @authenticated and @visibility == :draft do %>
|
||||||
|
<div>Draft — not published</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @authenticated and @visibility == :scheduled do %>
|
||||||
|
<div>This post is scheduled for ...</div>
|
||||||
|
<% end %>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `@authenticated` flag comes from `conn.assigns[:current_scope]` (BlogController line 33):
|
||||||
|
```elixir
|
||||||
|
authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What would need to change to add a link to the editor dashboard in the main nav?
|
||||||
|
|
||||||
|
### Single-file change: `app/lib/firehose_web/components/layouts/app.html.heex`
|
||||||
|
|
||||||
|
The app layout receives `@current_scope` from the `fetch_current_scope_for_user` plug in the browser pipeline (router.ex line 16). Currently it doesn't use it.
|
||||||
|
|
||||||
|
**Required addition:** A conditional link inside the `<ul class="flex flex-row ...">` that shows only when `@current_scope` has a user:
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<%= if @current_scope && @current_scope.user do %>
|
||||||
|
<li>
|
||||||
|
<a href="/editor/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
```
|
||||||
|
|
||||||
|
No router changes needed — the route and LiveView already exist with proper authentication guards.
|
||||||
|
|
||||||
|
**Optionally**, also add login/logout links for unauthenticated users, so they know how to reach the dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What would be the simplest way to make drafts/scheduled visible to editors?
|
||||||
|
|
||||||
|
### Recommendation: Link the existing dashboard in the nav
|
||||||
|
|
||||||
|
The EditorDashboardLive at `/editor/dashboard` is fully functional — it shows all drafts and scheduled posts with proper sorting and status badges. The **only** missing piece is discoverability.
|
||||||
|
|
||||||
|
**Simplest change:**
|
||||||
|
1. Add a conditional "Dashboard" link to `app.html.heex` (as described in #4 above) — **~3 lines of code**
|
||||||
|
2. Optionally add login/logout links so users can authenticate
|
||||||
|
|
||||||
|
**Why this is sufficient:**
|
||||||
|
- The dashboard is already built and tested
|
||||||
|
- It respects authentication (only shows for logged-in users)
|
||||||
|
- It covers both drafts **and** scheduled (unlike dev-mode `all_posts()` which only covers drafts)
|
||||||
|
- Posts link to their public URLs for preview
|
||||||
|
|
||||||
|
### Alternative considered: Add drafts/scheduled to public blog index
|
||||||
|
|
||||||
|
This would be more invasive — it would require:
|
||||||
|
- Changing `BlogController.index` to check for authentication and use `unfiltered_posts()` when authenticated
|
||||||
|
- Adding an auth plug to the blog routes (currently no auth pipeline there)
|
||||||
|
- Modifying templates to show status badges
|
||||||
|
|
||||||
|
This approach would leak draft titles to unauthenticated users if not done carefully, and is more complex than simply linking the existing dashboard.
|
||||||
|
|
||||||
|
### Live post preview (already exists)
|
||||||
|
|
||||||
|
Authenticated users can already view individual draft/scheduled posts by direct URL (`/blog/engineering/some-draft-slug`) with a status banner — `BlogController.show` uses `get_post!()` which returns unfiltered posts. The show template checks `@authenticated` to display the draft/scheduled banner.
|
||||||
225
investigation/question-2-rss-prominence.md
Normal file
225
investigation/question-2-rss-prominence.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# Question 2: How can we make RSS feeds more obvious to viewers?
|
||||||
|
|
||||||
|
## 1. Where are the RSS/Atom feeds currently served?
|
||||||
|
|
||||||
|
**Application router** (`app/lib/firehose_web/router.ex`, lines 39-41):
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
scope "/api/blog" do
|
||||||
|
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
|
||||||
|
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The Blogex.Router is mounted under `/api/blog/{engineering,releases}`. This means feeds are at:
|
||||||
|
|
||||||
|
| Feed | URL |
|
||||||
|
|------|-----|
|
||||||
|
| Engineering RSS | `/api/blog/engineering/feed.xml` |
|
||||||
|
| Engineering Atom | `/api/blog/engineering/atom.xml` |
|
||||||
|
| Release Notes RSS | `/api/blog/releases/feed.xml` |
|
||||||
|
| Release Notes Atom | `/api/blog/releases/atom.xml` |
|
||||||
|
|
||||||
|
**Blogex.Router** (`blogex/lib/blogex/router.ex`, lines 27-61) handles:
|
||||||
|
|
||||||
|
- `GET /feed.xml` (line 27) — RSS 2.0 via `Blogex.Feed.rss/2`
|
||||||
|
- `GET /atom.xml` (line 39) — Atom feed via `Blogex.Feed.atom/2`
|
||||||
|
|
||||||
|
The feeds are **not mounted under the human-facing `/blog/{id}/` paths** but under `/api/blog/{id}/`. This is suboptimal for usability — users browsing at `/blog/engineering` won't find a feed at `/blog/engineering/feed.xml`.
|
||||||
|
|
||||||
|
**Feed generation** (`blogex/lib/blogex/feed.ex`):
|
||||||
|
|
||||||
|
- `Blogex.Feed.rss/2` generates RSS 2.0 with proper XML, using `all_posts()`, respecting a `:limit` option (default 20).
|
||||||
|
- `Blogex.Feed.atom/2` generates Atom feed with same post data.
|
||||||
|
- Both include a self-referencing `<atom:link>` in RSS / `<link rel="self">` in Atom (for feed reader validation).
|
||||||
|
- Blog/author info is read from the blog module's `title/0`, `description/0`, `base_path/0` functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Is there any RSS icon or link in the current UI?
|
||||||
|
|
||||||
|
**No. There are zero references to RSS/Atom/feed in any template.**
|
||||||
|
|
||||||
|
| Location | File | Line(s) | Has RSS link? |
|
||||||
|
|----------|------|---------|---------------|
|
||||||
|
| Navigation bar | `app/lib/firehose_web/components/layouts/app.html.heex` | 1-32 | **No** |
|
||||||
|
| Homepage | `app/lib/firehose_web/controllers/page_html/home.html.heex` | 1-38 | **No** |
|
||||||
|
| Blog index | `app/lib/firehose_web/controllers/blog_html/index.html.heex` | 1-9 | **No** |
|
||||||
|
| Blog post show | `app/lib/firehose_web/controllers/blog_html/show.html.heex` | 1-23 | **No** |
|
||||||
|
| Blog tag page | `app/lib/firehose_web/controllers/blog_html/tag.html.heex` | 1-10 | **No** |
|
||||||
|
| HTML `<head>` | `app/lib/firehose_web/components/layouts/root.html.heex` | 1-40 | **No** |
|
||||||
|
|
||||||
|
Confirmed by codebase grep — `grep -rn "feed\|rss\|atom" app/lib/firehose_web/` returned only comments and false matches (e.g. `"feed"` in error handling, not RSS).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Best places to add RSS feed links
|
||||||
|
|
||||||
|
### (A) HTML `<head>` — Autodiscovery (highest priority)
|
||||||
|
|
||||||
|
**File:** `app/lib/firehose_web/components/layouts/root.html.heex`
|
||||||
|
|
||||||
|
Two `<link>` tags per blog for RSS autodiscovery (feed readers use these to auto-detect feeds):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Engineering Blog" href="/api/blog/engineering/feed.xml" />
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Engineering Blog (Atom)" href="/api/blog/engineering/atom.xml" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Release Notes" href="/api/blog/releases/feed.xml" />
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Release Notes (Atom)" href="/api/blog/releases/atom.xml" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Challenge:** The root layout (`root.html.heex`) is shared across all pages (hompage, about, contact, blog pages). Hardcoding both blogs' feeds works, but ideally only the currently-relevant blog's feed links appear on blog-specific pages. Since the app router uses a single root layout, you'd need to pass the blog context from the controller into the layout assigns, or use `@content_for`/`put_layout` approaches.
|
||||||
|
|
||||||
|
**Option A1 (simple):** Hardcode both feeds in `<head>` unconditionally. Acceptable because:
|
||||||
|
- The site only has two blogs with stable URLs.
|
||||||
|
- Readers can subscribe to either.
|
||||||
|
- Zero configuration needed.
|
||||||
|
|
||||||
|
**Option A2 (precise):** Add blog-specific metadata to the assigns in `BlogController` (lines 14-28, 31-46) and pass it to the layout via `@content_for`, similar to how `meta_tags` are already handled (line 20: `FirehoseWeb.Layouts.put_content_for(:meta_tags, meta)`). This would require adding a new `@content_for` slot for feed links.
|
||||||
|
|
||||||
|
### (B) Blog index pages (app/lib/firehose_web/controllers/blog_html/index.html.heex)
|
||||||
|
|
||||||
|
Add an RSS icon next to the blog title in the `<header>` section (line 4):
|
||||||
|
|
||||||
|
```
|
||||||
|
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to include a small RSS icon link pointing at the appropriate feed URL. The `@base_path` assign is already available (set in `BlogController.index/2`, line 24). We can derive the feed URL as `/api/blog/engineering/feed.xml` when `@base_path` is `/blog/engineering`, but there's no direct mapping. A helper mapping function would be needed, or a `@blog_id` assign should be added to the controller.
|
||||||
|
|
||||||
|
**Option B1:** Add a `blog_id` assign to the controller, then in the template do:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
<a href={"/api/blog/#{@blog_id}/feed.xml"} class="..." title="RSS feed">
|
||||||
|
<.rss_icon />
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B2 (simpler):** Hardcode the mapping in the template or add a helper function in `BlogHTML`:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defp feed_url("/blog/engineering"), do: "/api/blog/engineering/feed.xml"
|
||||||
|
defp feed_url("/blog/releases"), do: "/api/blog/releases/feed.xml"
|
||||||
|
```
|
||||||
|
|
||||||
|
### (C) Blog post pages (app/lib/firehose_web/controllers/blog_html/show.html.heex)
|
||||||
|
|
||||||
|
Add a subtle "Subscribe via RSS" link or icon near the "Back to posts" link (line 2). The `@base_path` and `@post` assigns are available.
|
||||||
|
|
||||||
|
Best placement: inline with the "Back to posts" link at the top, or as a small badge/icon in the post metadata area (which is rendered by `Blogex.Components.post_meta/1` in `blogex/lib/blogex/components.ex`, line 76).
|
||||||
|
|
||||||
|
### (D) Homepage (app/lib/firehose_web/controllers/page_html/home.html.heex)
|
||||||
|
|
||||||
|
Add an RSS icon or link near the "Recent posts" heading (line 21, currently just `<h2>Recent posts</h2>`). Since the homepage shows posts from both blogs, you could:
|
||||||
|
|
||||||
|
- Link to the Engineering blog feed (primary blog)
|
||||||
|
- Or show a dropdown between the two feeds
|
||||||
|
- Or link to both (Engineering RSS, Releases RSS)
|
||||||
|
|
||||||
|
### (E) Navigation bar (app/lib/firehose_web/components/layouts/app.html.heex)
|
||||||
|
|
||||||
|
More prominent placement: add an RSS icon as a nav item (after the Contact link, line 25). Could be a dropdown linking to both feeds, or just the Engineering blog feed as default. This makes RSS discoverable from any page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HTML for RSS autodiscovery links in `<head>`
|
||||||
|
|
||||||
|
The standard pattern for RSS 2.0 autodiscovery is:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Engineering Blog" href="/api/blog/engineering/feed.xml" />
|
||||||
|
```
|
||||||
|
|
||||||
|
And for Atom:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Engineering Blog (Atom)" href="/api/blog/engineering/atom.xml" />
|
||||||
|
```
|
||||||
|
|
||||||
|
For both blogs, this would be 4 `<link>` tags inserted in `root.html.heex` between the favicon links (line 9) and the `<.live_title>` component (line 10), or after line 15 (after the title).
|
||||||
|
|
||||||
|
**Autodiscovery works with relative paths** (feed readers resolve them against the page URL), so using `/api/blog/engineering/feed.xml` is fine and avoids needing the full host URL.
|
||||||
|
|
||||||
|
### Recommended implementation for `root.html.heex`
|
||||||
|
|
||||||
|
Insert after line 14 (`</.live_title>`) and before the CSS link (line 15):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Engineering Blog" href="/api/blog/engineering/feed.xml" />
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Engineering Blog (Atom)" href="/api/blog/engineering/atom.xml" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Release Notes" href="/api/blog/releases/feed.xml" />
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Release Notes (Atom)" href="/api/blog/releases/atom.xml" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Adding RSS subscription icons while keeping the design clean
|
||||||
|
|
||||||
|
### Icon options
|
||||||
|
|
||||||
|
Use an inline SVG (no additional dependencies), a commonly recognized RSS icon, approximately 16-20px, matching the current text color. The standard RSS icon is the orange/white dot-and-waves — but matching the site's monochrome theme (text color / primary color) would be more cohesive.
|
||||||
|
|
||||||
|
The current design uses:
|
||||||
|
- `text-base-content` for text
|
||||||
|
- `text-primary` for accent links
|
||||||
|
- Consistently uses `btn btn-ghost btn-sm` for nav links
|
||||||
|
|
||||||
|
### Placement strategies (from subtle to prominent)
|
||||||
|
|
||||||
|
1. **Only autodiscovery** (no visible icon) — functional for feed readers, invisible to human visitors. Minimum viable.
|
||||||
|
|
||||||
|
2. **Small RSS icon in blog header** — next to the blog title on index pages. Clean and contextual.
|
||||||
|
|
||||||
|
3. **Small RSS icon next to "Recent posts" on homepage** — visible on the landing page.
|
||||||
|
|
||||||
|
4. **RSS icon as a nav item** — consistently visible from any page:
|
||||||
|
```
|
||||||
|
[Blog] [Releases] [QWAN] [Contact] [📡] [theme toggle]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Combination**: Autodiscovery + blog index icons + optionally a nav item.
|
||||||
|
|
||||||
|
### Recommended approach (clean design)
|
||||||
|
|
||||||
|
Use a small inline SVG RSS icon (matching the `text-base-content/60` muted color) placed:
|
||||||
|
|
||||||
|
- **In `<head>`** for autodiscovery (always, no visual impact)
|
||||||
|
- **In blog index header** (a small icon right-aligned next to the page title, or an inline link reading "RSS" in subtle text)
|
||||||
|
- **Optionally in the navbar** as a subtle icon-only button
|
||||||
|
|
||||||
|
### Implementation details
|
||||||
|
|
||||||
|
For the blog index (`index.html.heex`), the current template at line 4:
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
Could become:
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<h1 class="text-3xl font-bold font-display flex items-center gap-3">
|
||||||
|
{@blog_title}
|
||||||
|
<a href={feed_url(@base_path)} class="text-base-content/40 hover:text-primary transition" title="Subscribe to RSS feed">
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93v-2.83Z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires a `feed_url/1` helper. Since `base_path` maps cleanly to the API route (`/blog/engineering` → `/api/blog/engineering/feed.xml`), a simple `String.replace_prefix(@base_path, "/blog", "/api/blog") <> "/feed.xml"` would work.
|
||||||
|
|
||||||
|
### Summary of implementation points
|
||||||
|
|
||||||
|
| Location | File | Action |
|
||||||
|
|----------|------|--------|
|
||||||
|
| `<head>` | `app/lib/firehose_web/components/layouts/root.html.heex` ~ line 14 | Add 4 `<link rel="alternate">` tags |
|
||||||
|
| Blog index | `app/lib/firehose_web/controllers/blog_html/index.html.heex` ~ line 4 | Add RSS icon next to title |
|
||||||
|
| Blog show | `app/lib/firehose_web/controllers/blog_html/show.html.heex` ~ line 2 | Add subtle RSS link near "Back to posts" |
|
||||||
|
| Homepage | `app/lib/firehose_web/controllers/page_html/home.html.heex` ~ line 21 | Add RSS icon near "Recent posts" heading |
|
||||||
|
| Navbar | `app/lib/firehose_web/components/layouts/app.html.heex` ~ line 25 | Optionally add RSS icon nav item |
|
||||||
|
| Controller | `app/lib/firehose_web/controllers/blog_controller.ex` ~ line 22 | Optionally add `blog_id` assign for feed URL mapping |
|
||||||
|
| Helper | `app/lib/firehose_web/controllers/blog_html.ex` | Add `feed_url/1` helper function |
|
||||||
|
|
||||||
|
**Note:** The current feed URLs under `/api/blog/` are slightly awkward. Consider adding the `feed.xml` and `atom.xml` routes to the main Blogex.Router *also* under the human-facing path (e.g., `/blog/engineering/feed.xml`) by mounting Blogex.Router at both paths, or by adding explicit routes. However, this would be a larger refactor — the simplest short-term approach is to use the existing `/api/blog/` URLs and just link to them prominently.
|
||||||
230
investigation/question-3-email-subscriptions.md
Normal file
230
investigation/question-3-email-subscriptions.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# Question 3: Email Subscriptions — Investigation
|
||||||
|
|
||||||
|
## 1. Does any email subscription functionality already exist?
|
||||||
|
|
||||||
|
**No. Zero.** The codebase contains no newsletter, subscription, or subscriber functionality whatsoever.
|
||||||
|
|
||||||
|
A grep for `newsletter`, `subscrib`, `subscriber`, `unsubscribe` across all `.ex` and `.heex` files in the project returned zero matches.
|
||||||
|
|
||||||
|
## 2. What existing email infrastructure is already in place?
|
||||||
|
|
||||||
|
### Mailer (Swoosh)
|
||||||
|
|
||||||
|
- **File:** `app/lib/firehose/mailer.ex` (line 1-3)
|
||||||
|
- Uses `Swoosh.Mailer`, otp_app: `:firehose`
|
||||||
|
- Config uses `Swoosh.Adapters.Local` in dev (config.exs, line 40)
|
||||||
|
- Config uses `Swoosh.Adapters.Test` in test (test.exs, line 24)
|
||||||
|
- Production uses `Swoosh.Adapters.SMTP` via env vars (runtime.exs, lines 54-65)
|
||||||
|
|
||||||
|
### UserNotifier (existing pattern for transactional emails)
|
||||||
|
|
||||||
|
- **File:** `app/lib/firehose/accounts/user_notifier.ex`
|
||||||
|
- Has two notification functions:
|
||||||
|
- `deliver_update_email_instructions/2` (line 41)
|
||||||
|
- `deliver_login_instructions/2` (line 53) — which dispatches to either `deliver_confirmation_instructions` or `deliver_magic_link_instructions`
|
||||||
|
- Internal `deliver/4` helper (line 19) constructs a `Swoosh.Email`, calls `Mailer.deliver/1`
|
||||||
|
- Uses `SENDER_NAME` and `SMTP_FROM_EMAIL` env vars (lines 12-13)
|
||||||
|
- Only sends plain-text emails (no HTML templates)
|
||||||
|
|
||||||
|
### Accounts Context
|
||||||
|
|
||||||
|
- **File:** `app/lib/firehose/accounts.ex`
|
||||||
|
- Has `deliver_user_update_email_instructions/3` and `deliver_login_instructions/2`
|
||||||
|
- Uses `UserToken.build_email_token/2` for token generation (hashed tokens stored in DB)
|
||||||
|
- Token expiry: 15 minutes for magic links, 7 days for email change
|
||||||
|
|
||||||
|
### Production SMTP config (runtime.exs, lines 46-64)
|
||||||
|
|
||||||
|
Uses env vars:
|
||||||
|
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_SSL`, `SMTP_TLS`
|
||||||
|
|
||||||
|
### No Oban or background job system
|
||||||
|
|
||||||
|
- **mix.exs** has no Oban dependency
|
||||||
|
- No GenServer for background email sending
|
||||||
|
- `app/lib/firehose/application.ex` — no Oban or job queue in the supervision tree
|
||||||
|
- Current mailer calls are synchronous (not ideal for bulk sending)
|
||||||
|
|
||||||
|
## 3. What database tables exist?
|
||||||
|
|
||||||
|
Only one migration exists:
|
||||||
|
|
||||||
|
**File:** `app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs`
|
||||||
|
|
||||||
|
Two tables:
|
||||||
|
- `users` — id, email (citext, unique), hashed_password, confirmed_at, timestamps
|
||||||
|
- `users_tokens` — id, user_id (FK), token (binary), context (string), sent_to (string), authenticated_at (utc_datetime), inserted_at (timestamps, no updated_at)
|
||||||
|
|
||||||
|
**No subscriber table, no subscription table, no newsletter table exists.**
|
||||||
|
|
||||||
|
## 4. What would a minimal email subscription feature need?
|
||||||
|
|
||||||
|
### 4.1 Database Model
|
||||||
|
|
||||||
|
**New table: `subscribers`**
|
||||||
|
|
||||||
|
| Column | Type | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| id | bigserial | PK |
|
||||||
|
| email | citext, unique, not null | Subscriber email |
|
||||||
|
| confirmed_at | utc_datetime, nullable | Set when double opt-in confirmed |
|
||||||
|
| unsubscribed_at | utc_datetime, nullable | Set when unsubscribed |
|
||||||
|
| token | binary, nullable | Confirmation token (hashed) |
|
||||||
|
| token_inserted_at | utc_datetime, nullable | When token was created |
|
||||||
|
| inserted_at | utc_datetime | Timestamps |
|
||||||
|
| updated_at | utc_datetime | Timestamps |
|
||||||
|
|
||||||
|
The `confirmed_at` field is the canonical way to know if a subscription is active (following the same pattern as `users.confirmed_at`).
|
||||||
|
|
||||||
|
### 4.2 Migration
|
||||||
|
|
||||||
|
Would create the `subscribers` table with:
|
||||||
|
- `citext` extension already exists (from the users migration, line 4)
|
||||||
|
- Unique index on email
|
||||||
|
- Index on `confirmed_at` for querying active subscribers
|
||||||
|
|
||||||
|
### 4.3 Subscription Form
|
||||||
|
|
||||||
|
**Where to place it:**
|
||||||
|
- Homepage (`app/lib/firehose_web/controllers/page_html/home.html.heex`) — add a "Subscribe to newsletter" section
|
||||||
|
- Blog index page (`app/lib/firehose_web/controllers/blog_controller.ex`) — could also have a subscription form
|
||||||
|
- Footer of the layout (`app/lib/firehose_web/components/layouts/app.html.heex`) — persistent subscription form
|
||||||
|
|
||||||
|
**Form would POST to a new SubscriptionController** (or use a LiveView component).
|
||||||
|
|
||||||
|
### 4.4 Confirmation Email Flow (Double Opt-In)
|
||||||
|
|
||||||
|
1. User enters email on subscription form
|
||||||
|
2. Server creates subscriber record with `confirmed_at: nil`, generates a hashed confirmation token
|
||||||
|
3. Server sends confirmation email with a link like `GET /newsletter/confirm/:token`
|
||||||
|
4. User clicks link → `confirmed_at` is set → subscription is active
|
||||||
|
5. User can unsubscribe via `GET /newsletter/unsubscribe/:token` (or a link in each email)
|
||||||
|
|
||||||
|
**Pattern to follow:** The existing `UserToken` + `UserNotifier` pattern in `app/lib/firehose/accounts/user_token.ex` shows how tokens are built, hashed, stored, and verified. The subscriber confirmation flow would be simpler — no need for the full `context` system, just a single-purpose token.
|
||||||
|
|
||||||
|
### 4.5 Sending New Posts to Subscribers
|
||||||
|
|
||||||
|
**Options (in order of complexity):**
|
||||||
|
|
||||||
|
1. **Simple GenServer** — Polls for new published posts every N minutes, sends to confirmed subscribers. No external dependency, but no retry/backoff.
|
||||||
|
2. **Oban** — Background job queue. Add `{:oban, "~> 2.18"}` to mix.exs. Schedule a recurring job (via Oban.Cron) to check for new posts and enqueue delivery jobs. Provides retries, observability, and proper error handling.
|
||||||
|
3. **Manual trigger** — When a post is published (from the editor dashboard), fan out to subscribers. Requires hooking into the post-publishing flow.
|
||||||
|
|
||||||
|
**Recommendation:** The GenServer approach is simpler, but Oban is more robust. The current codebase has no background job system, so either would be an addition.
|
||||||
|
|
||||||
|
**How to know which posts are new:** The Blogex system compiles posts at build-time from markdown files, so there's no "published_at" event. A polling approach would need to track which posts have already been sent (e.g., a `sent_posts` table tracking `post_id` + `sent_at`).
|
||||||
|
|
||||||
|
### 4.6 Unsubscribe Mechanism
|
||||||
|
|
||||||
|
- Each email would include a link like `https://example.com/newsletter/unsubscribe?token=<token>`
|
||||||
|
- The token is a hashed version of the subscriber's ID or a dedicated unsubscribe token
|
||||||
|
- Clicking the link sets `unsubscribed_at` and the subscriber is excluded from all future sends
|
||||||
|
- No login required — one-click unsubscribe
|
||||||
|
|
||||||
|
### 4.7 New Files Needed
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app/lib/firehose/newsletter/subscriber.ex` | Ecto schema for `subscribers` table |
|
||||||
|
| `app/lib/firehose/newsletter.ex` | Context module (CRUD, subscribe, confirm, unsubscribe) |
|
||||||
|
| `app/lib/firehose/newsletter/notifier.ex` | Email sending (confirmation, new post) |
|
||||||
|
| `app/priv/repo/migrations/*_create_subscribers.exs` | Migration |
|
||||||
|
| `app/lib/firehose_web/controllers/newsletter_controller.ex` | Handle confirm/unsubscribe links |
|
||||||
|
| OR `app/lib/firehose_web/live/newsletter_live.ex` | LiveView component for subscription form |
|
||||||
|
| `app/lib/firehose/newsletter/sender.ex` | GenServer or Oban worker for sending to subscribers |
|
||||||
|
|
||||||
|
## 5. Roughly how complex is this? Estimate the scope.
|
||||||
|
|
||||||
|
### Complexity: Medium (roughly 2-3 days of focused work)
|
||||||
|
|
||||||
|
**Breakdown by layer:**
|
||||||
|
|
||||||
|
| Layer | Effort | Details |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| DB schema + migration | 1-2 hours | Simple table, following existing patterns |
|
||||||
|
| Ecto schema + context | 2-3 hours | CRUD, changeset validations, subscribe/confirm/unsubscribe |
|
||||||
|
| Confirmation email flow | 2-3 hours | Token generation, email sending, link handling |
|
||||||
|
| Subscription form UI | 2-3 hours | Form on homepage, validation, success/error states |
|
||||||
|
| Unsubscribe mechanism | 1-2 hours | Token-based one-click unsubscribe |
|
||||||
|
| Email sending to subscribers | 4-6 hours | The bulk of the work — polling mechanism, tracking sent posts, formatting emails |
|
||||||
|
| Tests | 3-4 hours | Following existing test patterns in `accounts_test.exs` |
|
||||||
|
| **Total** | **~15-22 hours** | |
|
||||||
|
|
||||||
|
**Risk factors:**
|
||||||
|
- The blog posts are compiled at build-time, so there's no "post published" event to hook into. A polling mechanism or a manual "send to subscribers" button in the editor dashboard would be needed.
|
||||||
|
- No background job system exists — either Oban needs to be added (additional dependency, migration) or a simpler GenServer approach is used.
|
||||||
|
- Sending bulk emails from a single-threaded GenServer could block. Oban would handle this properly with concurrent workers.
|
||||||
|
|
||||||
|
**Simpler alternative (MVP):** A "Subscribe to newsletter" form that stores emails, with a manual CSV export workflow. No automated sending. This would be ~half the effort but would not be a real solution.
|
||||||
|
|
||||||
|
## 6. Existing patterns to follow
|
||||||
|
|
||||||
|
### User Auth Token Pattern (for confirmation tokens)
|
||||||
|
|
||||||
|
**File:** `app/lib/firehose/accounts/user_token.ex`
|
||||||
|
|
||||||
|
The `UserToken` module shows:
|
||||||
|
- Tokens are generated with `:crypto.strong_rand_bytes(32)` (line 58)
|
||||||
|
- Tokens are hashed with `:crypto.hash(:sha256, token)` before storage (line 79)
|
||||||
|
- Raw tokens are Base64-url-encoded for URLs (line 80)
|
||||||
|
- Verification decodes, re-hashes, and queries the database (lines 97-112)
|
||||||
|
- Context system distinguishes token types (e.g., "session", "login", "change:email")
|
||||||
|
|
||||||
|
For subscriber confirmation, we could either:
|
||||||
|
- Reuse `users_tokens` table with a new context like `"subscribe_confirm"`
|
||||||
|
- Create a simpler token field directly on the `subscribers` table
|
||||||
|
|
||||||
|
The simpler approach (token field on `subscribers` table) is recommended to avoid coupling to the auth system.
|
||||||
|
|
||||||
|
### User Notifier Pattern (for email sending)
|
||||||
|
|
||||||
|
**File:** `app/lib/firehose/accounts/user_notifier.ex`
|
||||||
|
|
||||||
|
The `deliver/4` function (line 19) constructs a `Swoosh.Email`:
|
||||||
|
```elixir
|
||||||
|
defp deliver(recipient, subject, body) do
|
||||||
|
email = new() |> to(recipient) |> from(sender()) |> subject(subject) |> text_body(body)
|
||||||
|
with {:ok, _metadata} <- Mailer.deliver(email), do: {:ok, email}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
A newsletter notifier would follow this pattern exactly, possibly adding HTML body support.
|
||||||
|
|
||||||
|
### Accounts Context Pattern (for CRUD operations)
|
||||||
|
|
||||||
|
**File:** `app/lib/firehose/accounts.ex`
|
||||||
|
|
||||||
|
The `Accounts` context module shows the standard pattern:
|
||||||
|
- `Repo.get_by/3` for lookups
|
||||||
|
- `changeset` + `Repo.insert/1` for creation
|
||||||
|
- `Repo.transact/1` for transactional operations
|
||||||
|
- `Repo.update/1` for updates
|
||||||
|
- Function naming convention: `subscribe/1`, `confirm_subscriber/1`, `unsubscribe/1`
|
||||||
|
|
||||||
|
### Scope Pattern (for authorization)
|
||||||
|
|
||||||
|
**File:** `app/lib/firehose/accounts/scope.ex`
|
||||||
|
|
||||||
|
For newsletter subscriptions, scopes aren't directly relevant (subscribers are anonymous). But the pattern shows how struct-based contexts are used.
|
||||||
|
|
||||||
|
### Migration Pattern
|
||||||
|
|
||||||
|
**File:** `app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs`
|
||||||
|
|
||||||
|
The migration shows:
|
||||||
|
- `execute "CREATE EXTENSION IF NOT EXISTS citext", ""` (line 4) — already exists
|
||||||
|
- `create table(:users)` with `timestamps(type: :utc_datetime)` (line 6)
|
||||||
|
- `create unique_index(:users, [:email])` (line 11)
|
||||||
|
|
||||||
|
### Test Fixture Pattern
|
||||||
|
|
||||||
|
**File:** `app/test/support/fixtures/accounts_fixtures.ex`
|
||||||
|
|
||||||
|
Shows the fixture pattern:
|
||||||
|
- `unique_user_email/0` — generates unique emails using `System.unique_integer()`
|
||||||
|
- `valid_user_attributes/1` — merges defaults with overrides
|
||||||
|
- `user_fixture/1` — creates a real DB record
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Email subscriptions do not exist in any form.** The infrastructure is there (Swoosh mailer, SMTP config, email sending patterns), but the feature is a clean-slate addition. The heaviest part would be the automated sending of new posts to subscribers, which requires either adding Oban or building a polling GenServer, and solving the "which posts are new" problem since posts are compiled at build-time rather than published at runtime.
|
||||||
Loading…
x
Reference in New Issue
Block a user