diff --git a/.github/workflows/lessons.yml b/.github/workflows/lessons.yml new file mode 100644 index 0000000..d28fa88 --- /dev/null +++ b/.github/workflows/lessons.yml @@ -0,0 +1,38 @@ +name: Lessons + +# The lessons quote the application's source. This checks the quotes are still +# accurate, so a change to elxrBB that contradicts a lesson fails here rather +# than in a reader's terminal. + +on: + push: + # Branches are covered by pull_request; this is for merges to main. + branches: [main] + pull_request: + workflow_dispatch: + schedule: + # The application can drift without this repo changing, so check weekly too. + - cron: "17 6 * * 1" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Check out the application the lessons teach + uses: actions/checkout@v5 + with: + repository: ephbaum/elxrBB + ref: ${{ vars.ELXRBB_REF || 'claude/modernization-implementation-puv702' }} + # lessons.exs pins each lesson to the commit that concludes it, so + # the checker needs history, not just a tip snapshot. + fetch-depth: 0 + path: .elxrbb + + - uses: erlef/setup-beam@v1 + with: + elixir-version: "1.20.4" + otp-version: "28.5.0.6" + + - run: elixir bin/check_lessons.exs --app .elxrbb diff --git a/README.md b/README.md index c590dbb..7d76c53 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,56 @@ # elxrBB-tutorial +Build a forum web application with Elixir and Phoenix, one lesson at a time. + +## Start here + +| | | +|---|---| +| [Outline](docs/00a-outline.md) | The whole series at a glance | +| [Introduction](docs/00b-introduction.md) | What elxrBB is and what it will do | +| [Lesson 1](docs/01-setting-up.md) | Environment setup and generating the project | +| [Lesson 2](docs/02-user-authentication.md) | Accounts, magic-link login, usernames and bios | +| [Lesson 3](docs/03-forum-functionality.md) | Forums, topics and replies | +| [Lesson 4](docs/04-threading-and-voting.md) | Threaded replies, sub-topics and voting | +| [Status](STATUS.md) | Which lessons are written, and which are implemented | + +The reference application lives at +[ephbaum/elxrBB](https://github.com/ephbaum/elxrBB). Lessons 1โ€“4 are written +against code that is actually in that repository and passes its test suite; +everything from lesson 5 on is still an outline. + +Written against Phoenix 1.8 on Elixir 1.20 / OTP 28, using `mix phx.gen.auth`, +LiveView, Tailwind 4 and daisyUI. + +## Checking the lessons + +The lessons quote the application's source, and quoted code goes stale. Blocks +that name their source file in the fence are checked against it: + + elixir bin/check_lessons.exs --app ../elxrBB + +Each lesson is checked against the commit that concludes it, named in +`lessons.exs` -- lesson 3 shows replies as a flat stream and lesson 4 replaces +that with a tree, so checking either against the application's HEAD would +report drift that is really the tutorial doing its job. + +Whitespace and whole-line comments are ignored, so a lesson may re-indent or +reflow an excerpt and leave out a comment it explains in prose. A line of bare +`...`, or a comment mentioning `...`, stands in for code the lesson is not +showing. Pass `--strict` to also fail on source blocks that name no file; not +every block names one yet, so the unannotated count is a backlog, not a bug. + +## Where this came from + A (an ambitious) collaborative effort with ChatGPT (GPT-4) to create a tutorial for, and open source, a forum web application -## What? +### What? This repo is the result of a recent conversation with [ChatGPT](https://help.openai.com/en/collections/3742473-chatgpt) ([GPT4](https://openai.com/research/gpt-4)). The clever little chatbot suggested in true ChatGPT Dunning-Kruger style, and I figured ๐Ÿคทโ€โ™‚๏ธ, okay, maybe we can build a real tutorial from this conversation Essentially, I asked ChatGPT about how to build a forum web app using Elixir and Phoenix -## Why? +### Why? The conversation around ChatGPT right now is wild. There's a lot of [doomers](https://www.reuters.com/technology/musk-experts-urge-pause-training-ai-systems-that-can-outperform-gpt-4-2023-03-29/) out there, [some moreso](https://time.com/6266923/ai-eliezer-yudkowsky-open-letter-not-enough/) than [others](https://astralcodexten.substack.com/p/why-i-am-not-as-much-of-a-doomer). @@ -28,7 +70,7 @@ There is something amazing about working with an always-there (except when you h ChatGPT is a hell of a "yes-man" and has a pretty wide array of knowledge to draw from- -## How? +### How? I am just working with ChatGPT to build both this tutorial and the application @@ -38,6 +80,6 @@ I do hope some folks might [contribute]() in the future, maybe even some folks w Or maybe it will be another repo gathering dust :shrug: -## Important Note +### Important Note I make no warranty. This is a work in progress and I have no idea what I'm doing (probably). ChatGPT and I seem to be having a pretty productive conversation, but this may all be made up bullshit. Do not rely on it until this document suggests otherwise. \ No newline at end of file diff --git a/STATUS.md b/STATUS.md index 4559a60..e3643c9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,56 +1,85 @@ # Tutorial Status -This document tracks the current status of each tutorial lesson and its corresponding implementation in the application repository. +Tracks each lesson and whether the reference application actually implements it. -## Lesson Status +Application repository: -| Lesson | Title | Status | Application Commit | Notes | -|--------|-------|--------|-------------------|-------| -| 1 | Setting Up the Environment | โœ… Complete | Not started | Clean, modern instructions | -| 2 | User Authentication with Pow | โœ… Complete | Not started | Updated configuration | -| 3 | Forum Functionality | ๐Ÿšง Planned | Not started | Implementation plan ready | -| 4 | Threaded Replies and Voting | ๐Ÿ“‹ Planned | Not started | Needs merging with lesson 5 | -| 5 | Sub-topics | ๐Ÿ“‹ Planned | Not started | Merge with lesson 4 | -| 6 | Private Messaging and Profiles | ๐Ÿ“‹ Planned | Not started | Needs modernization | +## Lessons -## Status Legend +Numbering follows the revised [outline](docs/00a-outline.md). -- โœ… **Complete** - Lesson is finished and validated -- ๐Ÿšง **Planned** - Implementation plan is ready -- ๐Ÿ“‹ **Planned** - Needs work before implementation -- โŒ **Incomplete** - Not ready for implementation +| # | Title | Lesson | Implemented | +|---|---|---|---| +| 1 | Setting Up the Environment | โœ… Written | โœ… Yes | +| 2 | User Accounts | โœ… Written | โœ… Yes | +| 3 | Forums, Topics and Replies | โœ… Written | โœ… Yes | +| 4 | Threaded Replies, Sub-Topics and Voting | โœ… Written | โœ… Yes | +| 5 | Real-Time Updates with PubSub | ๐Ÿšง Next | ๐Ÿšง Next | +| 6 | Pagination and Search | ๐Ÿ“‹ Outlined | โŒ No | +| 7 | Profiles, Avatars and Private Messaging | ๐Ÿ“‹ Outlined | โš ๏ธ Profiles shipped in lesson 2 | +| 8 | Rich Text and Safe Rendering | ๐Ÿ“‹ Outlined | โŒ No | +| 9 | Roles, Permissions and Moderation | ๐Ÿ“‹ Outlined | โŒ No | +| 10 | Audit Trails and the Admin Dashboard | ๐Ÿ“‹ Outlined | โŒ No | +| 11 | Notifications | ๐Ÿ“‹ Outlined | โŒ No | +| 12 | Accessible Design | ๐Ÿ“‹ Outlined | โŒ No | +| 13 | Testing in Depth | ๐Ÿ“‹ Outlined | โš ๏ธ App is tested; lesson not written | +| 14 | Deploying elxrBB | ๐Ÿ“‹ Outlined | โŒ No | +| 15 | Customizing and Extending | ๐Ÿ“‹ Outlined | โŒ No | +| Aโ€“D | Appendices | ๐Ÿ“‹ Outlined | โŒ No | -## Implementation Progress +Legend: โœ… done ยท ๐Ÿšง in progress ยท โš ๏ธ partial ยท ๐Ÿ“‹ planned ยท โŒ not started -### Application Repository -- **Repository**: https://github.com/ephbaum/elxrBB -- **Current Status**: Fresh start with documentation -- **Next Step**: Begin Lesson 1 implementation +## What the application does today -### Tutorial Repository -- **Repository**: https://github.com/ephbaum/elxrBB-tutorial -- **Current Status**: Lessons 1-2 cleaned, Lesson 3 planned -- **Next Step**: Complete remaining lesson plans +- Accounts: registration, magic-link login, email confirmation, password and + email changes, sudo mode for sensitive edits. +- Profiles: an auto-assigned `GerundAnimal` username (544,116 possible names) + plus a bio, both editable from the settings page. +- Forums: create, edit, delete. Public listing with topic counts. +- Topics: created inside a forum by a signed-in user. Forum pages list them + with reply counts, ordered by most recent activity. +- Replies: posted from the topic page, nested up to five levels deep, with a + sub-reply count on every parent. Authors can edit and delete their own posts; + deleting one removes its subtree. +- Voting: up and down votes on topics and replies, one per user per post, + revocable. Threads and forum listings can be sorted by score. -## Validation Process +231 tests pass on `mix precommit`. -Each lesson will be validated by: -1. Following the tutorial instructions -2. Implementing in the application repository -3. Testing functionality -4. Updating this status document -5. Linking to application commits +## Known gaps in the application -## Historical Context +These are true of the code today and are scheduled, not forgotten: -- **Original Lessons**: Archived in `docs/archive/original-lessons/` -- **Original Application**: Archived in `archive/initial-attempt` branch -- **Project Restart**: January 2025 +- **No pagination.** `list_topics/1` and `list_replies/1` return everything. + Lesson 6. +- **No real-time.** A posted reply or vote appears for its author only; other + readers must reload. Lesson 5. +- **No search.** Lesson 6. +- **Post bodies render as plain text.** No Markdown, and therefore no + sanitization question to answer yet. Lesson 8. +- **Anyone signed in can create, edit or delete a forum.** Only topics and + replies are author-restricted. Roles land in lesson 9. +- **No rate limiting.** Not on the outline; worth adding before this is ever + exposed to the public internet. -## Next Steps +## Deviations from the original plan -1. **Implement Lesson 1** - Environment setup -2. **Implement Lesson 2** - User authentication -3. **Implement Lesson 3** - Forum functionality -4. **Update remaining lessons** - Modernize and complete -5. **Validate all lessons** - Ensure tutorial/application alignment +The outline itself was revised once lessons 1โ€“3 existed; see +[Revisions to this plan](docs/00a-outline.md#revisions-to-this-plan) for the +full account. In short: lessons 4 and 5 were one lesson, real-time moved much +earlier and switched from Channels to PubSub, pagination and search were +missing entirely, testing moved earlier, the animal-names lesson was dropped +as superseded by lesson 2, and the vendor-dependent material (payments, SMS, +browser push) became optional appendices. + +## How a lesson gets marked done + +1. The lesson is written against code that exists. +2. The code is in the application repository and `mix precommit` passes. +3. Every command and snippet in the lesson was run, not assumed. +4. This file is updated. + +## Archive + +- Original lessons (2023, ChatGPT-assisted): `docs/archive/original-lessons/` +- Original application: the `archive/initial-attempt` branch of the app repo diff --git a/bin/check_lessons.exs b/bin/check_lessons.exs new file mode 100755 index 0000000..8577905 --- /dev/null +++ b/bin/check_lessons.exs @@ -0,0 +1,276 @@ +#!/usr/bin/env elixir + +# Verifies that annotated code blocks in the lessons still match the +# application they teach. A block is annotated by naming its source file in +# the fence: +# +# ```elixir lib/elxrbb/forums.ex +# def list_forums do +# ... +# end +# ``` +# +# Every non-elided run of lines must appear, in order, in the named file. +# Whitespace is ignored entirely in the comparison, and so are comments that +# occupy a whole line, so a lesson may re-indent an excerpt, reflow it to the +# page width, or leave out a comment it explains in prose instead. It may not +# change a name, a literal, or the structure. +# +# Elision markers split a block into chunks: a line of bare `...`, or a +# whole-line comment mentioning `...` (`# ... generated fields ...`). Each +# chunk must match whole, and the chunks must appear in the file in the order +# the lesson shows them. +# +# Each lesson is checked against the ref named for it in lessons.exs, not +# against the application's HEAD -- a lesson describes the application as it +# stood when that lesson ended, and later lessons are meant to change it. +# +# elixir bin/check_lessons.exs [--app PATH] [--strict] +# +# --app application checkout to verify against (default ../elxrBB, or +# $ELXRBB_APP) +# --strict also fail on source blocks that carry no annotation + +defmodule CheckLessons do + @source_langs ~w(elixir heex eex sql) + + def main(argv) do + {opts, _rest} = OptionParser.parse!(argv, strict: [app: :string, strict: :boolean]) + + app = Path.expand(opts[:app] || System.get_env("ELXRBB_APP") || "../elxrBB", root()) + strict? = Keyword.get(opts, :strict, false) + + File.dir?(app) || abort("application checkout not found at #{app} (pass --app PATH)") + + manifest = manifest() + blocks = root() |> lesson_files() |> Enum.flat_map(&parse/1) + {annotated, bare} = Enum.split_with(blocks, & &1.source) + + refs = resolve_refs(app, manifest, annotated) + failures = annotated |> Enum.map(&verify(&1, app, refs)) |> Enum.reject(&is_nil/1) + unannotated = Enum.filter(bare, &(&1.lang in @source_langs)) + + Enum.each(failures, &report/1) + if strict?, do: Enum.each(unannotated, &report_unannotated/1) + + summarize(annotated, unannotated, failures, refs) + + cond do + failures != [] -> System.halt(1) + strict? and unannotated != [] -> System.halt(1) + true -> :ok + end + end + + defp root, do: __DIR__ |> Path.join("..") |> Path.expand() + + defp lesson_files(dir), do: dir |> Path.join("docs/*.md") |> Path.wildcard() |> Enum.sort() + + defp manifest do + path = Path.join(root(), "lessons.exs") + + if File.regular?(path) do + {map, _bindings} = Code.eval_file(path) + map + else + %{} + end + end + + # Refs --------------------------------------------------------------------- + + # A lesson with no manifest entry, or one naming a ref the application + # checkout does not have yet, falls back to the working tree. + defp resolve_refs(app, manifest, blocks) do + blocks + |> Enum.map(&Path.basename(&1.doc)) + |> Enum.uniq() + |> Map.new(fn lesson -> + case Map.fetch(manifest, lesson) do + {:ok, ref} -> {lesson, if(commit?(app, ref), do: ref, else: {:worktree, ref})} + :error -> {lesson, {:worktree, nil}} + end + end) + end + + defp commit?(app, ref) do + {_out, status} = + System.cmd("git", ["-C", app, "rev-parse", "--verify", "--quiet", ref <> "^{commit}"], + stderr_to_stdout: true + ) + + status == 0 + end + + defp read_source(app, {:worktree, _ref}, path) do + file = Path.join(app, path) + if File.regular?(file), do: {:ok, File.read!(file)}, else: :error + end + + defp read_source(app, ref, path) do + case System.cmd("git", ["-C", app, "show", "#{ref}:#{path}"], stderr_to_stdout: true) do + {contents, 0} -> {:ok, contents} + {_error, _status} -> :error + end + end + + # Parsing ------------------------------------------------------------------ + + defp parse(path) do + path + |> File.read!() + |> String.split("\n") + |> Enum.with_index(1) + |> collect(path, [], nil) + |> Enum.reverse() + end + + defp collect([], _path, acc, nil), do: acc + + defp collect([], path, acc, open) do + abort("#{path}:#{open.line} code block is never closed") + acc + end + + defp collect([{line, number} | rest], path, acc, nil) do + case fence_info(line) do + nil -> + collect(rest, path, acc, nil) + + {lang, source} -> + open = %{lang: lang, source: source, line: number, doc: path, body: []} + collect(rest, path, acc, open) + end + end + + defp collect([{line, _number} | rest], path, acc, open) do + if closing_fence?(line) do + collect(rest, path, [%{open | body: Enum.reverse(open.body)} | acc], nil) + else + collect(rest, path, acc, %{open | body: [line | open.body]}) + end + end + + defp fence_info("```" <> info) do + case info |> String.trim() |> String.split(~r/\s+/, trim: true) do + [] -> {nil, nil} + [lang] -> {lang, nil} + [lang, source | _] -> {lang, source} + end + end + + defp fence_info(_line), do: nil + + defp closing_fence?(line), do: String.trim_trailing(line) == "```" + + # Verification ------------------------------------------------------------- + + defp verify(block, app, refs) do + ref = Map.fetch!(refs, Path.basename(block.doc)) + + case read_source(app, ref, block.source) do + :error -> + %{block: block, ref: ref, reason: {:missing_file, block.source}} + + {:ok, contents} -> + case block.body |> chunks() |> match_chunks(squash(contents)) do + :ok -> nil + {:error, chunk} -> %{block: block, ref: ref, reason: {:no_match, chunk}} + end + end + end + + # A line that is nothing but a comment is presentation, not code: the lesson + # may carry one the source lacks, or explain it in prose instead. + @line_comment ~r{^\s*(?:\#|//|--(?!\S)| -
-
-
-

- Create your account -

-
- - <.simple_form for={@changeset} action={~p"/registration"} method="post"> - <.input field={@changeset[:email]} type="email" label="Email" required /> - <.input field={@changeset[:password]} type="password" label="Password" required /> - <.input field={@changeset[:password_confirmation]} type="password" label="Confirm Password" required /> - <.input field={@changeset[:username]} type="text" label="Username" required /> - <.input field={@changeset[:bio]} type="textarea" label="Bio (optional)" /> - - <:actions> - <.button class="w-full">Register - - - -
- <.link href={~p"/session/new"} class="text-indigo-600 hover:text-indigo-500"> - Already have an account? Sign in - -
-
-
-``` +```elixir lib/elxrbb_web/live/user_live/settings.ex +def handle_event("validate_profile", %{"user" => user_params}, socket) do + profile_form = + socket.assigns.current_scope.user + |> Accounts.change_user_profile(user_params, validate_unique: false) + |> Map.put(:action, :validate) + |> to_form() -## Adding Authentication to Your Application + {:noreply, assign(socket, profile_form: profile_form)} +end -### Protect Routes +def handle_event("update_profile", %{"user" => user_params}, socket) do + user = socket.assigns.current_scope.user + true = Accounts.sudo_mode?(user) -To protect routes that require authentication, add a plug: + case Accounts.update_user_profile(user, user_params) do + {:ok, user} -> + {:noreply, + socket + |> put_flash(:info, "Profile updated.") + |> assign(:current_scope, ElxrBB.Accounts.Scope.for_user(user)) + |> assign(:profile_form, to_form(Accounts.change_user_profile(user, %{}, validate_unique: false)))} -```elixir -# lib/elxrBB_web/router.ex -pipeline :protected do - plug Pow.Plug.RequireAuthenticated, - error_handler: Pow.Phoenix.PlugErrorHandler + {:error, changeset} -> + {:noreply, assign(socket, :profile_form, to_form(changeset, action: :insert))} + end end +``` -scope "/", ElxrBBWeb do - pipe_through [:browser, :protected] +Re-assigning `:current_scope` after a successful save matters: the nav bar +reads the username out of the scope, and without it the old name lingers until +the next full page load. - # Protected routes go here - get "/dashboard", DashboardController, :index -end +### The nav bar + +In `lib/elxrbb_web/components/layouts/root.html.heex`, swap the email for the +username: + +```heex lib/elxrbb_web/components/layouts/root.html.heex +
  • + {@current_scope.user.username} +
  • ``` -### Access Current User +## Testing -In your controllers and LiveViews, you can access the current user: +The interesting tests are the ones about the generator and about case +sensitivity: ```elixir -# In a controller -def index(conn, _params) do - user = Pow.Plug.current_user(conn) - # ... rest of controller logic -end +test "pairs a gerund verb with an animal" do + verbs = MapSet.new(Username.verbs()) + animals = MapSet.new(Username.animals()) + + for _ <- 1..200 do + name = Username.generate() + + assert name =~ ~r/^[A-Za-z]+$/ + assert String.length(name) <= Username.max_length() -# In a LiveView -def mount(_params, _session, socket) do - user = Pow.Plug.current_user(socket.assigns[:__changed__][:conn]) - # ... rest of mount logic + assert Enum.any?(verbs, fn verb -> + String.starts_with?(name, verb) and + MapSet.member?(animals, String.replace_prefix(name, verb, "")) + end) + end end -``` -## Next Steps +test "rejects another user's username, ignoring case", %{user: user} do + other = user_fixture() -In the next lesson, we'll implement the core forum functionality, including forums, topics, and replies. The authentication system we've built will be used to associate forum content with users. + assert {:error, changeset} = + Accounts.update_user_profile(user, %{username: String.downcase(other.username)}) -## Troubleshooting + assert "has already been taken" in errors_on(changeset).username +end -### Common Issues +test "keeping your own username is not a conflict", %{user: user} do + assert {:ok, _user} = Accounts.update_user_profile(user, %{username: user.username}) +end +``` -**"No route found" errors:** +That last one is the classic uniqueness bug: a naive "is this name taken?" +check says yes when you save your profile without changing your name. The +availability query excludes the current record's own id. -- Ensure `pow_routes()` and `pow_extension_routes()` are in your router -- Check that the Pow session plug is in your endpoint +Run the suite: -**Email confirmation not working:** +```bash +mix precommit +``` -- Verify the mailer is properly configured -- Check that email confirmation is enabled in the Pow config +## Try it -**Database errors:** +```bash +mix phx.server +``` -- Run `mix ecto.migrate` to ensure all migrations are applied -- Check that the User schema includes all required Pow fields +1. Register at . +2. Open and click the login link. +3. Look at the nav bar โ€” you have been named after an animal. +4. Change it at . -## Additional Resources +## Next -- [Pow Documentation](https://hexdocs.pm/pow/README.html) -- [Pow Phoenix Integration](https://hexdocs.pm/pow/Phoenix.html) -- [Pow Extensions](https://hexdocs.pm/pow/Pow.Extension.html) +[Lesson 3](03-forum-functionality.md) builds the forum itself: forums, +topics and replies, with the accounts from this lesson as their authors. diff --git a/docs/03-forum-functionality.md b/docs/03-forum-functionality.md index ce170e5..b6dee11 100644 --- a/docs/03-forum-functionality.md +++ b/docs/03-forum-functionality.md @@ -1,198 +1,407 @@ -# Lesson 3: Implementing Basic Forum Functionality +# Lesson 3: Forums, Topics and Replies -## Status: ๐Ÿšง INCOMPLETE - PLANNED +## Overview -**Note**: This lesson is currently incomplete. The forum functionality described below needs to be implemented in the application. +This is the lesson where elxrBB becomes a forum. We build three tables, one +context, and five LiveViews: -## Overview +- **Forums** group discussion by theme. Anyone can read them. +- **Topics** are threads inside a forum, written by a user. +- **Replies** are posts inside a topic, written by a user. -In this lesson, we'll implement the core forum functionality for elxrBB, including: +Reading is public. Posting requires an account. Editing and deleting require +being the author. -- Forum categories -- Discussion topics -- Topic replies -- User associations -- Basic CRUD operations +## The database -## Planned Implementation +### Forums -### 1. Database Schema Design +```elixir priv/repo/migrations/20260902035623_create_forums.exs +create table(:forums) do + add :name, :string, null: false + add :description, :text, null: false -We'll create three main entities: + timestamps(type: :utc_datetime) +end +create unique_index(:forums, ["lower(name)"], name: :forums_lower_name_index) ``` -Forums (Categories) -โ”œโ”€โ”€ id -โ”œโ”€โ”€ name (string) -โ”œโ”€โ”€ description (text) -โ”œโ”€โ”€ created_at -โ””โ”€โ”€ updated_at -Topics (Discussions) -โ”œโ”€โ”€ id -โ”œโ”€โ”€ title (string) -โ”œโ”€โ”€ body (text) -โ”œโ”€โ”€ forum_id (references forums) -โ”œโ”€โ”€ user_id (references users) -โ”œโ”€โ”€ created_at -โ””โ”€โ”€ updated_at +Same case-insensitive uniqueness trick as usernames in lesson 2. + +### Topics and replies + +```elixir priv/repo/migrations/20260902035650_create_topics.exs +create table(:topics) do + add :title, :string, null: false + add :body, :text, null: false + add :forum_id, references(:forums, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false -Replies (Comments) -โ”œโ”€โ”€ id -โ”œโ”€โ”€ body (text) -โ”œโ”€โ”€ topic_id (references topics) -โ”œโ”€โ”€ user_id (references users) -โ”œโ”€โ”€ created_at -โ””โ”€โ”€ updated_at + timestamps(type: :utc_datetime) +end + +create index(:topics, [:forum_id, "inserted_at DESC"]) +create index(:topics, [:user_id]) ``` -### 2. Phoenix Generators +```elixir priv/repo/migrations/20260902035651_create_replies.exs +create table(:replies) do + add :body, :text, null: false + add :topic_id, references(:topics, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false -We'll use Phoenix generators to create the contexts and LiveViews: + timestamps(type: :utc_datetime) +end -```bash -# Create Forum context -mix phx.gen.live Forums Forum forums name:string description:text +create index(:replies, [:topic_id, "inserted_at ASC"]) +create index(:replies, [:user_id]) +``` -# Create Topic context -mix phx.gen.live Forums Topic topics title:string body:text forum_id:references:forums user_id:references:users +**`on_delete: :delete_all` is enforced by PostgreSQL**, not by Ecto. Deleting a +forum removes its topics and their replies in one statement, with no orphans +possible even if something writes to the database outside your app. + +**The indexes match the queries, not the columns.** A forum page reads topics +for one `forum_id` ordered by time, so the index covers both. A thread reads +replies for one `topic_id` ordered the other way. Indexing `forum_id` alone +would still leave the database sorting on every page load. + +## The schemas + +Nothing surprising, but note where the associations point: + +```elixir +# lib/elxrbb/forums/topic.ex +schema "topics" do + field :title, :string + field :body, :string + + # Filled in by the list queries below; not database columns. + field :replies_count, :integer, virtual: true + field :last_posted_at, :utc_datetime, virtual: true + + belongs_to :forum, Forum + belongs_to :user, User + has_many :replies, Reply + + timestamps(type: :utc_datetime) +end -# Create Reply context -mix phx.gen.live Forums Reply replies body:text topic_id:references:topics user_id:references:users +def changeset(topic, attrs) do + topic + |> cast(attrs, [:title, :body]) + |> update_change(:title, &trim/1) + |> validate_required([:title, :body]) + |> validate_length(:title, min: 3, max: 200) + |> validate_length(:body, min: 1, max: 20_000) + |> assoc_constraint(:forum) + |> assoc_constraint(:user) +end ``` -### 3. Database Migrations +The changeset casts `:title` and `:body` and **not** `:forum_id` or `:user_id`. +Authorship is not something a form gets to submit. The context sets it. -After running the generators, we'll need to: +`assoc_constraint/2` turns a foreign-key violation into a changeset error +instead of an exception โ€” which is what you want if a forum is deleted between +rendering the form and submitting it. -- Run `mix ecto.migrate` to create the database tables -- Add proper indexes for performance -- Set up foreign key constraints +## The context -### 4. Context Functions +`ElxrBB.Forums` is the whole business layer. Two design decisions run through +it. -The Forums context will include: +**Writes take an explicit author.** -```elixir -# Forum functions -- list_forums/0 -- get_forum!/1 -- create_forum/1 -- update_forum/2 -- delete_forum/1 +```elixir lib/elxrbb/forums.ex +def create_topic(%Forum{} = forum, %User{} = user, attrs) do + %Topic{forum_id: forum.id, user_id: user.id} + |> Topic.changeset(attrs) + |> Repo.insert() + |> preload_after_write([:forum, :user]) +end +``` + +The context never reaches for an ambient "current user". It is handed one. That +keeps it testable without a connection, and makes it obvious at every call site +who is writing. -# Topic functions -- list_topics/0 -- list_topics_by_forum/1 -- get_topic!/1 -- create_topic/2 (attrs, user) -- update_topic/2 -- delete_topic/1 +**Authorization is a predicate, not a side effect.** -# Reply functions -- list_replies_by_topic/1 -- get_reply!/1 -- create_reply/2 (attrs, user) -- update_reply/2 -- delete_reply/1 +```elixir lib/elxrbb/forums.ex +def topic_owner?(%Topic{user_id: user_id}, %User{id: user_id}), do: true +def topic_owner?(_topic, _user), do: false ``` -### 5. LiveView Implementation +Two clauses, matching on the same `user_id` binding twice so the ids must be +equal. The fall-through covers a different user *and* `nil` โ€” an anonymous +visitor owns nothing. The web layer uses the same function to decide what to +render and to decide whether to accept a write. + +### Counting replies without an N+1 and without a counter column + +A forum page shows every topic with its reply count and the time of its most +recent post. The obvious implementations are both bad: a query per topic (N+1), +or a `replies_count` column that has to be kept correct forever. + +The third option is one aggregate subquery: + +```elixir lib/elxrbb/forums.ex +defp topics_with_counts do + reply_stats = + from(r in Reply, + group_by: r.topic_id, + select: %{topic_id: r.topic_id, count: count(r.id), last_posted_at: max(r.inserted_at)} + ) + + from(t in Topic, + left_join: s in subquery(reply_stats), + on: s.topic_id == t.id, + order_by: [desc: coalesce(s.last_posted_at, t.inserted_at), desc: t.id], + preload: [:user], + select: %{ + t + | replies_count: coalesce(s.count, 0), + last_posted_at: type(coalesce(s.last_posted_at, t.inserted_at), :utc_datetime) + } + ) +end +``` -We'll create LiveViews for: +Points of interest: -- Forum listing page -- Topic listing page (by forum) -- Individual topic view with replies -- Topic creation form -- Reply creation form +- `left_join` plus `coalesce` gives a topic with no replies a count of `0` + rather than dropping it or returning `nil`. +- Ordering by "most recent post, falling back to when the topic was created" + floats active threads to the top, which is what a forum should do. +- **`type/2` is not optional.** `max(r.inserted_at)` loses its Ecto type on the + way out of the subquery, so without it the virtual field comes back as a + `NaiveDateTime` even though it is declared `:utc_datetime`. Anything + pattern-matching on `%DateTime{}` downstream then fails. This one cost a test + run to find; the test is in `forums_test.exs`. +- `select: %{t | ...}` merges into the struct, so callers still get a `%Topic{}` + and not a bare map. -### 6. Router Configuration +## The web layer -```elixir +### Routes + +```elixir lib/elxrbb_web/router.ex +# Posting requires an account. +scope "/", ElxrBBWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :forums_authenticated, + on_mount: [{ElxrBBWeb.UserAuth, :require_authenticated}] do + live "/forums/new", ForumLive.Form, :new + live "/forums/:id/edit", ForumLive.Form, :edit + live "/forums/:forum_id/topics/new", TopicLive.Form, :new + live "/topics/:id/edit", TopicLive.Form, :edit + live "/replies/:id/edit", ReplyLive.Form, :edit + end +end + +# Browsing is public; the scope is still mounted so authors see their controls. scope "/", ElxrBBWeb do pipe_through :browser - # Forum routes - live "/forums", ForumLive.Index, :index - live "/forums/new", ForumLive.Index, :new - live "/forums/:id/edit", ForumLive.Index, :edit - live "/forums/:id", ForumLive.Show, :show - live "/forums/:id/show/edit", ForumLive.Show, :edit - - # Topic routes - live "/topics", TopicLive.Index, :index - live "/topics/new", TopicLive.Index, :new - live "/topics/:id/edit", TopicLive.Index, :edit - live "/topics/:id", TopicLive.Show, :show - live "/topics/:id/show/edit", TopicLive.Show, :edit - - # Reply routes - live "/replies", ReplyLive.Index, :index - live "/replies/new", ReplyLive.Index, :new - live "/replies/:id/edit", ReplyLive.Index, :edit - live "/replies/:id", ReplyLive.Show, :show - live "/replies/:id/show/edit", ReplyLive.Show, :edit + live_session :forums_public, + on_mount: [{ElxrBBWeb.UserAuth, :mount_current_scope}] do + live "/forums", ForumLive.Index, :index + live "/forums/:id", ForumLive.Show, :show + live "/topics/:id", TopicLive.Show, :show + end +end +``` + +**Order matters.** Phoenix matches routes in declaration order, so +`/forums/new` has to come before `/forums/:id` or "new" is parsed as an id and +`Repo.get!` raises. Putting the authenticated scope first handles it. + +**Two `live_session`s, two `on_mount` hooks.** `:require_authenticated` +redirects a signed-out visitor to the login page. `:mount_current_scope` +assigns the scope and lets them through โ€” `@current_scope` is `nil` for a +guest, which templates check directly: + +```heex lib/elxrbb_web/live/forum_live/index.ex +<.button :if={@current_scope} variant="primary" navigate={~p"/forums/new"}> + <.icon name="hero-plus" /> New forum + +``` + +A LiveView cannot move between live sessions without a full page navigation, so +keep `navigate={...}` rather than `patch={...}` on links that cross the +boundary. + +### Checking ownership twice + +`TopicLive.Form` checks `topic_owner?/2` at mount, to decide whether to render +the form at all: + +```elixir +defp apply_action(socket, :edit, %{"id" => id}) do + topic = Forums.get_topic!(id) + + if Forums.topic_owner?(topic, socket.assigns.current_scope.user) do + socket + |> assign(:page_title, "Edit topic") + |> assign(:topic, topic) + |> assign(:form, to_form(Forums.change_topic(topic))) + else + socket + |> put_flash(:error, "You can only edit your own topics.") + |> push_navigate(to: ~p"/topics/#{topic}") + end end ``` -### 7. User Interface +...and again on save: -The UI will include: +```elixir lib/elxrbb_web/live/topic_live/form.ex +defp save_topic(socket, :edit, topic_params) do + %{topic: topic, current_scope: %{user: user}} = socket.assigns -- Forum listing with topic counts -- Topic listing with reply counts -- Topic view with threaded replies -- Forms for creating topics and replies -- Navigation between forums and topics + if Forums.topic_owner?(topic, user) do + # ... update ... + else + # ... refuse ... + end +end +``` -### 8. User Associations +That is not redundant. A LiveView holds a websocket open, and anything on the +other end of it can send any event it likes. **The mount-time check decides +what to render; it does not decide what to accept.** Every handler that writes +re-checks. The same applies to `delete_reply` and `delete_topic` in +`TopicLive.Show`. + +### Streams for the reply list + +`TopicLive.Show` keeps replies in a LiveView stream rather than an assign, so +posting a reply appends one `
  • ` instead of re-rendering the thread: + +```elixir lib/elxrbb_web/live/topic_live/show.ex +def handle_event("save_reply", %{"reply" => reply_params}, socket) do + case Forums.create_reply(socket.assigns.topic, current_user(socket), reply_params) do + {:ok, reply} -> + {:noreply, + socket + |> update(:replies_count, &(&1 + 1)) + |> assign_reply_form() + |> stream_insert(:replies, reply)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, reply_form: to_form(changeset))} + end +end +``` -- Topics will be associated with users (authors) -- Replies will be associated with users (authors) -- Users will be able to edit/delete their own content -- User information will be displayed with posts +The catch with streams: the server does not keep the list, so anything derived +from it โ€” here, the reply count in the heading โ€” has to be tracked separately. +`update(:replies_count, &(&1 + 1))` on insert, `&(&1 - 1)` on delete. -## Implementation Steps +The markup needs `phx-update="stream"` on the container and an `id` on each +child: -1. **Generate the contexts and LiveViews** -2. **Run database migrations** -3. **Update the router with new routes** -4. **Customize the LiveView templates** -5. **Add user associations and permissions** -6. **Test the basic CRUD operations** -7. **Add navigation and UI improvements** +```heex lib/elxrbb_web/live/topic_live/show.ex +
      +
    • + ... +
    • +
    +``` -## Dependencies +## A bug worth repeating -This lesson builds on: +Both the forum and the topic changesets trim whitespace: -- Lesson 1: Phoenix setup and project structure -- Lesson 2: User authentication system +```elixir lib/elxrbb/forums/forum.ex +|> update_change(:name, &trim/1) +``` + +Write that as `&String.trim/1` and the app crashes with a +`FunctionClauseError` the moment someone submits an empty name. `cast/3` puts +`nil` in the changes, `update_change/3` calls the function with it, and +`String.trim/1` only accepts binaries. The nil-tolerant version lets +`validate_required` do its job: + +```elixir lib/elxrbb/forums/forum.ex +defp trim(nil), do: nil +defp trim(value) when is_binary(value), do: String.trim(value) +``` + +The generated `@invalid_attrs` test โ€” the one you are tempted to delete because +it "just checks the obvious" โ€” is what catches this. -## Next Lessons +## Seeds -This lesson provides the foundation for: +`priv/repo/seeds.exs` creates five starter forums, and skips ones that already +exist so it is safe to re-run: + +```elixir priv/repo/seeds.exs +existing = MapSet.new(Forums.list_forums(), &String.downcase(&1.name)) + +for attrs <- forums, not MapSet.member?(existing, String.downcase(attrs.name)) do + {:ok, forum} = Forums.create_forum(attrs) + IO.puts("created forum #{forum.name}") +end +``` -- Lesson 4: Threaded replies and voting system -- Lesson 5: Advanced forum features -- Lesson 6: User profiles and private messaging +## Testing -## Current Status +Fixtures take their associations as options, creating them on demand: + +```elixir test/support/fixtures/forums_fixtures.ex +def topic_fixture(attrs \\ %{}) do + {forum, attrs} = Map.pop_lazy(Map.new(attrs), :forum, &forum_fixture/0) + {user, attrs} = Map.pop_lazy(attrs, :user, &user_fixture/0) + + attrs = Enum.into(attrs, %{title: "some title", body: "some body"}) + + {:ok, topic} = Forums.create_topic(forum, user, attrs) + topic +end +``` + +`Map.pop_lazy/3` means a test that does not care about the forum does not pay +to create one explicitly, and a test that does can pass `forum: forum`. + +The tests worth writing are the ones about permissions, because they are the +ones a click-through will not cover: + +```elixir test/elxrbb_web/live/topic_live_test.exs +test "a visitor cannot delete someone else's reply", %{conn: conn} do + topic = topic_fixture() + reply = reply_fixture(topic: topic) + + {:ok, lv, _html} = conn |> log_in_user(user_fixture()) |> live(~p"/topics/#{topic}") + + assert lv |> render_click("delete_reply", %{"id" => reply.id}) =~ + "You can only delete your own replies." + + assert [_] = Forums.list_replies(topic) +end +``` + +`render_click/3` pushes the event straight at the LiveView, bypassing the +markup โ€” which is exactly what a hostile client does. If your only check is +`refute html =~ "Delete"`, you have tested that the button is hidden, not that +the action is refused. + +## Try it + +```bash +mix setup +mix phx.server +``` -- [ ] Database schema design -- [ ] Phoenix generator commands -- [ ] Database migrations -- [ ] Context implementation -- [ ] LiveView implementation -- [ ] Router configuration -- [ ] User interface -- [ ] User associations -- [ ] Testing and validation +1. โ€” the seeded forums, with topic counts. +2. Register (lesson 2), then open a forum and press **New topic**. +3. Post a reply and watch it appear without a page load. +4. Sign out and reload: the thread is still readable, the controls are gone. -## Notes +## Next -- This lesson will use Phoenix LiveView for real-time updates -- We'll implement proper user permissions and associations -- The forum structure will be hierarchical: Forums โ†’ Topics โ†’ Replies -- All content will be associated with authenticated users +[Lesson 4](04-threading-and-voting.md) lets replies answer other replies, and +adds voting on top of this structure. diff --git a/docs/04-threading-and-voting.md b/docs/04-threading-and-voting.md new file mode 100644 index 0000000..755b169 --- /dev/null +++ b/docs/04-threading-and-voting.md @@ -0,0 +1,530 @@ +# Lesson 4: Threaded Replies, Sub-Topics and Voting + +## Overview + +Two features, one shape: + +- **Threading.** A reply can answer another reply, not just the topic. That is + all "sub-topics" ever meant โ€” the original outline described the same feature + twice, under two names. +- **Voting.** Members can upvote and downvote topics and replies, and sort by + the result. + +Both are small schema changes and a large amount of care about correctness. + +## Part one: threading + +### One column, and two constraints that keep it honest + +```elixir priv/repo/migrations/20260902052043_add_threading_to_replies.exs +# priv/repo/migrations/..._add_threading_to_replies.exs +@max_depth 5 + +def change do + alter table(:replies) do + add :parent_id, references(:replies, on_delete: :delete_all) + add :depth, :integer, null: false, default: 0 + end + + create index(:replies, [:parent_id]) + + create constraint(:replies, :replies_depth_within_bounds, + check: "depth >= 0 AND depth <= #{@max_depth}" + ) + + create constraint(:replies, :replies_depth_matches_parent, + check: "(depth = 0) = (parent_id IS NULL)" + ) +end +``` + +**A self-referencing foreign key.** `replies.parent_id` points at `replies.id`. +With `on_delete: :delete_all`, deleting a reply deletes its children, whose +deletion deletes *their* children, all the way down โ€” PostgreSQL walks the +self-reference itself. There is no recursive delete in application code, and +no orphaned subtree if a row is removed by anything other than your app. + +**Why store `depth` at all?** It is derivable: walk up the parents and count. +But you need it on the way *in* โ€” before inserting a reply you must know +whether its parent is already at the limit โ€” and walking the chain is a query +per level. One integer, set once at insert, answers it with the row you +already have. + +Denormalizing means the two facts can disagree, which is what the second +constraint is for: a reply is at depth zero exactly when it has no parent. +`(depth = 0) = (parent_id IS NULL)` compares two booleans, and PostgreSQL is +happy to do that. + +### Schema + +```elixir +# lib/elxrbb/forums/reply.ex +@max_depth 5 + +schema "replies" do + field :body, :string + field :depth, :integer, default: 0 + + # Filled in by ElxrBB.Forums when a thread is loaded; not columns. + field :score, :integer, virtual: true, default: 0 + field :user_vote, :integer, virtual: true + field :descendants_count, :integer, virtual: true, default: 0 + field :children, {:array, :map}, virtual: true, default: [] + + belongs_to :topic, Topic + belongs_to :user, User + belongs_to :parent, __MODULE__ + has_many :children_replies, __MODULE__, foreign_key: :parent_id + has_many :votes, Vote + + timestamps(type: :utc_datetime) +end + +def max_depth, do: @max_depth +``` + +`belongs_to :parent, __MODULE__` is how a schema references itself. The +`has_many :children_replies` is the other half โ€” note it needs an explicit +`foreign_key:`, because Ecto would otherwise look for `reply_id`. + +`:children` is a *virtual* field, separate from the `has_many`. The +association is how you would preload one level through Ecto; `:children` is +where we hang the tree we assemble ourselves, which is a different job. + +### Creating a nested reply + +```elixir lib/elxrbb/forums.ex +def create_reply(%Topic{} = topic, %User{} = user, attrs, opts \\ []) do + case resolve_parent(topic, Keyword.get(opts, :parent)) do + {:ok, parent} -> + %Reply{ + topic_id: topic.id, + user_id: user.id, + parent_id: parent && parent.id, + depth: (parent && parent.depth + 1) || 0 + } + |> Reply.changeset(attrs) + |> Repo.insert() + |> preload_after_write([:user]) + + {:error, reason} -> + {:error, reply_parent_error(attrs, reason)} + end +end + +defp resolve_parent(_topic, nil), do: {:ok, nil} + +defp resolve_parent(topic, %Reply{} = parent) do + cond do + parent.topic_id != topic.id -> {:error, :wrong_topic} + parent.depth >= Reply.max_depth() -> {:error, :too_deep} + true -> {:ok, parent} + end +end + +defp resolve_parent(topic, parent_id) do + case Repo.get(Reply, parent_id) do + nil -> {:error, :not_found} + parent -> resolve_parent(topic, parent) + end +end +``` + +Three things are being defended here, and each is a real attack or a real bug: + +1. **A parent that does not exist.** The id came from a form field. +2. **A parent in a different topic.** Also from a form field. Without this + check, a reply can be grafted onto a thread it does not belong to, and it + would render there โ€” the tree builder groups by `parent_id`, and would + happily place it. +3. **A parent already at maximum depth.** Refused, not silently flattened. + Quietly re-parenting someone's reply somewhere they did not choose is worse + than telling them no. + +Failures come back as `{:error, changeset}` with the message on `:parent_id`, +so the caller handles them exactly like a validation error. + +Note `resolve_parent/2` accepts a `%Reply{}` *or* an id. The struct clause is +the one that does the work; the id clause loads and delegates. That way a +LiveView which already has the reply does not re-fetch it, and a caller which +only has an id does not have to. + +### Building the tree + +The context loads a thread flat and assembles it in memory: + +```elixir lib/elxrbb/forums.ex +def list_reply_tree(topic_id, user, opts) do + replies = list_replies(topic_id) + tallies = vote_tallies(:reply_id, Enum.map(replies, & &1.id), user) + + replies + |> Enum.map(&apply_tally(&1, tallies)) + |> build_tree(Keyword.get(opts, :order_by, :oldest)) +end + +defp build_tree(replies, order) do + by_parent = Enum.group_by(replies, & &1.parent_id) + + attach(Map.get(by_parent, nil, []), by_parent, order) +end + +defp attach(replies, by_parent, order) do + replies + |> sort_siblings(order) + |> Enum.map(fn reply -> + children = attach(Map.get(by_parent, reply.id, []), by_parent, order) + + %{ + reply + | children: children, + descendants_count: + Enum.reduce(children, length(children), &(&1.descendants_count + &2)) + } + end) +end +``` + +**Two queries, whatever the depth.** One for every reply in the topic, one for +the vote tallies. Contrast the naive approach โ€” preload children, then their +children โ€” which is a query per level, or a recursive CTE, which is a query but +harder to read and does not help here because we want every reply anyway. + +`Enum.group_by(replies, & &1.parent_id)` puts the roots under the key `nil`, +which is exactly the entry point. The recursion is bounded by `max_depth`, so +there is no risk of running away โ€” and if a cycle somehow got into the data, +the depth constraint would have rejected it at insert. + +`descendants_count` accumulates on the way back up: each reply's count is its +direct children plus everything their counts already include. One pass, no +second traversal. + +**Ordering applies within a level.** `sort_siblings/2` orders each set of +siblings, so a well-rated reply rises among its peers but never escapes its +parent โ€” the thread stays a conversation. + +```elixir lib/elxrbb/forums.ex +defp sort_siblings(replies, :oldest), do: Enum.sort_by(replies, &{&1.inserted_at, &1.id}) +defp sort_siblings(replies, :newest), do: Enum.sort_by(replies, &{&1.inserted_at, &1.id}, :desc) +defp sort_siblings(replies, :score), do: Enum.sort_by(replies, &{-&1.score, &1.id}) +``` + +The `&1.id` tiebreak is not decoration. Two replies posted in the same second +have equal `inserted_at` at `:utc_datetime` precision, and without a tiebreak +their order is whatever the database felt like โ€” which means it can *change +between renders*, and LiveView will dutifully reorder the page under the +reader. + +## Part two: voting + +### One table for two kinds of target + +A vote belongs to a topic or to a reply. The options are two tables, or one +table with two nullable foreign keys. We take the second: + +```elixir priv/repo/migrations/20260902052044_create_votes.exs +create table(:votes) do + add :value, :integer, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :topic_id, references(:topics, on_delete: :delete_all) + add :reply_id, references(:replies, on_delete: :delete_all) + + timestamps(type: :utc_datetime) +end + +create constraint(:votes, :votes_value_is_up_or_down, check: "value IN (-1, 1)") + +create constraint(:votes, :votes_have_exactly_one_target, + check: "(topic_id IS NULL) <> (reply_id IS NULL)" + ) + +create unique_index(:votes, [:user_id, :topic_id], + where: "topic_id IS NOT NULL", + name: :votes_user_topic_index + ) + +create unique_index(:votes, [:user_id, :reply_id], + where: "reply_id IS NOT NULL", + name: :votes_user_reply_index + ) +``` + +Both foreign keys stay real, so the database still enforces that a vote points +at a row that exists and cleans up when it does not. What it cannot infer is +that *exactly one* should be set, so we say so: on booleans, PostgreSQL's `<>` +is exclusive or. + +**The unique indexes have to be partial.** "One vote per user per topic" is +`unique_index(:votes, [:user_id, :topic_id])` โ€” except every reply vote has +`topic_id IS NULL`, and NULLs do not collide in a unique index, so those rows +would all sit there harmlessly. Which is fine. What is *not* fine is leaving it +implicit: `where: "topic_id IS NOT NULL"` says what you mean and keeps the +index small. + +Storing the value as `1` and `-1` rather than a boolean means the score is +`SUM(value)`, which the database does directly. + +### Casting a vote + +```elixir +def vote(post, %User{} = user, value) when value in [-1, 1] do + {field, id} = vote_target(post) + + result = + case Repo.get_by(Vote, [{:user_id, user.id}, {field, id}]) do + nil -> + %Vote{user_id: user.id} + |> Ecto.Changeset.change(%{field => id}) + |> Vote.changeset(%{value: value}) + |> Repo.insert() + + %Vote{value: ^value} = existing -> + Repo.delete(existing) + + existing -> + existing + |> Vote.changeset(%{value: value}) + |> Repo.update() + end + + case result do + {:ok, _vote} -> {:ok, refresh_score(post, user)} + {:error, changeset} -> {:error, changeset} + end +end + +defp vote_target(%Topic{id: id}), do: {:topic_id, id} +defp vote_target(%Reply{id: id}), do: {:reply_id, id} +``` + +`vote_target/1` is the whole polymorphism: two clauses that turn a struct into +the column it lives in. Everything downstream is uniform, and adding a third +votable thing later is one more clause. + +Three cases, and the middle one is the interesting one. `%Vote{value: ^value}` +pins the argument โ€” if the existing vote matches the one being cast, the vote +is *removed*. That is what every voting UI a reader has ever used does: click +up once to upvote, click it again to take it back. Switching direction moves +the score by two, which surprises people who expect one, and is correct. + +### Reading scores without an N+1 + +```elixir lib/elxrbb/forums.ex +defp vote_tallies(field, ids, user) do + scores = + from(v in Vote, + where: field(v, ^field) in ^ids, + group_by: field(v, ^field), + select: {field(v, ^field), sum(v.value)} + ) + |> Repo.all() + |> Map.new() + + mine = + case user do + %User{id: user_id} -> + from(v in Vote, + where: field(v, ^field) in ^ids and v.user_id == ^user_id, + select: {field(v, ^field), v.value} + ) + |> Repo.all() + |> Map.new() + + _ -> + %{} + end + + %{scores: scores, mine: mine} +end +``` + +`field(v, ^field)` is how Ecto refers to a column chosen at runtime. It works +in `where`, `group_by` and `select` alike, which is what lets one function +serve both kinds of vote. + +Two queries decorate an arbitrary number of posts, and the second is skipped +entirely for a reader who is not signed in โ€” there is no "my vote" to look up. + +**No counter column.** A `score` column on topics and replies would be faster +to read and permanently at risk of drifting from the votes that produced it. +Sum the rows. Revisit only when a profiler says to. + +## The web layer + +### A recursive function component + +HEEx components can call themselves: + +```heex lib/elxrbb_web/live/topic_live/show.ex +<.reply_thread + :if={reply.children != []} + replies={reply.children} + current_scope={@current_scope} + replying_to={@replying_to} + reply_form={@reply_form} + nested +/> +``` + +The recursion terminates because the tree the context hands us is bounded by +`Reply.max_depth/0` โ€” the same constant the database enforces. A component +that recurses on unbounded data is a stack overflow waiting for a deep enough +thread. + +### Inline reply forms, and a DOM id collision + +Clicking **Reply** on a post sets `@replying_to` to that reply's id, and the +form renders under it. Simple enough โ€” except the first version put two forms +on the page whose textareas both had `id="reply_body"`, because Phoenix derives +input ids from the form's name. + +The LiveView test suite fails loudly on that: + +``` +** (RuntimeError) Duplicate id found while testing LiveView: reply_body +``` + +Which is a gift. Duplicate ids do not throw in a browser; they just make DOM +patching target the wrong element, and you find out later when a keystroke +lands in the wrong box. The fix is to name each input for its form: + +```heex +<.input field={@form[:body]} id={"#{form_id(@target)}-body"} type="textarea" ... /> +``` + +### Streams versus a tree + +Lesson 3 rendered replies from a LiveView stream. This lesson replaces that +with a plain assign, reloaded after each change. + +That is a real trade, made deliberately. A stream is a flat list the server +does not keep; a thread is a tree the server has to hold to render. Keeping +both would mean maintaining the tree separately from the stream and reconciling +them on every insert, for no benefit at the size a thread actually reaches. +Lesson 6 adds pagination, which is when this decision gets revisited. + +### Trusting ids from the client + +Every `phx-value-*` on the page arrives as a plain string in an event payload, +and anything holding the websocket open can send whatever it likes: + +```elixir +def handle_event("vote", %{"kind" => kind, "id" => id, "value" => value}, socket) do + case {current_user(socket), to_id(id), to_id(value)} do + {nil, _id, _value} -> + {:noreply, put_flash(socket, :error, "You must be logged in to vote.")} + + {user, post_id, direction} when not is_nil(post_id) and direction in [-1, 1] -> + {:noreply, cast_vote(socket, kind, post_id, direction, user)} + + _ -> + {:noreply, socket} + end +end + +defp cast_vote(socket, "reply", id, value, user) do + topic_id = socket.assigns.topic.id + + case Forums.get_reply(id) do + %Reply{topic_id: ^topic_id} = reply -> + {:ok, _} = Forums.vote(reply, user, value) + load_replies(socket) + + _ -> + socket + end +end +``` + +Three habits worth keeping: + +**Parse, do not trust.** `String.to_integer/1` raises on `"abc"`, and a raise +in `handle_event/3` kills the LiveView process. `Integer.parse/1` behind a +`to_id/1` helper returns `nil` instead, and `nil` falls through to the +do-nothing clause. + +**Check the id addresses something in scope.** A reply id is only acceptable if +that reply belongs to the topic *this* LiveView is showing. Without the pin on +`topic_id`, one open page is a lever on every reply in the database. + +**Use the non-raising getter.** `get_reply!/1` raises `Ecto.NoResultsError` on +a made-up id โ€” a 500 for the sender and noise in your logs. `get_reply/1` +returns `nil`, which the same clause already handles. + +## Testing + +The tests worth writing here are the ones about arithmetic and about refusal. + +```elixir +test "changing direction moves the score by two, not one", %{topic: topic, user: user} do + {:ok, topic} = Forums.vote(topic, user, 1) + assert {:ok, topic} = Forums.vote(topic, user, -1) + + assert topic.score == -1 + assert topic.user_vote == -1 +end + +test "refuses to nest deeper than the maximum" do + deepest = + Enum.reduce(1..Reply.max_depth(), reply_fixture(), fn _, parent -> + reply_fixture(parent: parent) + end) + + topic = Forums.get_topic!(deepest.topic_id) + + assert {:error, changeset} = + Forums.create_reply(topic, user_fixture(), %{body: "too deep"}, parent: deepest) + + assert "this thread cannot be nested any deeper" in errors_on(changeset).parent_id +end + +test "the vote value is constrained at the database, not only in Elixir" do + assert_raise Ecto.ConstraintError, ~r/votes_value_is_up_or_down/, fn -> + Repo.insert!(%Vote{user_id: user.id, topic_id: topic.id, value: 7}) + end +end +``` + +That last one bypasses the changeset on purpose. A validation you have only +tested through the changeset is a validation you have only tested in Elixir; +this asserts the database would refuse the row too. Note it is +`Ecto.ConstraintError`, not `Postgrex.Error` โ€” Ecto catches the constraint +violation and re-raises it with advice about `check_constraint/3`. + +And the LiveView equivalents, which push events a hostile client would push: + +```elixir test/elxrbb_web/live/topic_thread_test.exs +test "a vote aimed at another topic's reply is ignored", %{conn: conn} do + topic = topic_fixture() + elsewhere = reply_fixture() + + {:ok, lv, _html} = conn |> log_in_user(user_fixture()) |> live(~p"/topics/#{topic}") + + lv |> render_click("vote", %{"kind" => "reply", "id" => elsewhere.id, "value" => "1"}) + + assert Forums.with_score(Forums.get_reply!(elsewhere.id)).score == 0 +end +``` + +## Try it + +```bash +mix phx.server +``` + +1. Open a topic, post a reply, then press **Reply** on it and post again. +2. Watch the sub-reply count appear on the parent. +3. Vote on a post. Vote the same way again โ€” the vote comes back off. +4. Nest five deep; the **Reply** action stops being offered. +5. Sort the thread by **Top rated** and watch siblings reorder in place. + +## What is still missing + +Open a second browser and reload: someone else's reply is not there until you +refresh. Everything in this lesson happens in one LiveView's own process. +Lesson 5 fixes that. + +## Next + +Lesson 5 makes the forum live for every reader at once, with +`Phoenix.PubSub`. It is [outlined](00a-outline.md) but not yet written. diff --git a/docs/archive/README.md b/docs/archive/README.md index f11b6cb..a63f84c 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -41,12 +41,19 @@ The original lessons were archived because: 3. They contain too much debugging narrative 4. They're incomplete and confusing for learners +## Also archived here + +- `02-user-authentication-pow.md` โ€” an earlier draft of lesson 2 built on the + Pow library. Lesson 2 now uses `mix phx.gen.auth`; this is kept for + reference. + ## Current Status -The cleaned-up and modernized lessons are now in the main `docs/` directory: -- `01-setting-up.md` - Modern Phoenix setup instructions -- `02-user-authentication.md` - Clean Pow configuration -- `03-forum-functionality.md` - Planned implementation +The current lessons live in `docs/`, and each one is written against code that +exists in the application repository: +- `01-setting-up.md` โ€” Phoenix 1.8 on Elixir 1.20 / OTP 28 +- `02-user-authentication.md` โ€” user accounts, usernames and bios +- `03-forum-functionality.md` โ€” forums, topics and replies ## Learning Value diff --git a/docs/archive/original-lessons/02-user-authentication-pow.md b/docs/archive/original-lessons/02-user-authentication-pow.md new file mode 100644 index 0000000..83c48a2 --- /dev/null +++ b/docs/archive/original-lessons/02-user-authentication-pow.md @@ -0,0 +1,381 @@ +# Lesson 2: User Authentication with Pow + +## Overview + +In this lesson, we'll implement user authentication for the elxrBB application using the Pow library. Pow provides a robust, production-ready authentication system with features like email confirmation, password reset, and session management. + +## Why Pow? + +Pow is a batteries-included authentication library for Phoenix that provides: + +- User registration and login +- Email confirmation +- Password reset functionality +- Session management +- Extensible architecture +- Production-ready security features + +## Adding Pow to the Project + +### 1. Add Pow Dependencies + +Add Pow to your `mix.exs` file: + +```elixir +# mix.exs +defp deps do + [ + # ... existing dependencies ... + {:pow, "~> 1.0.29"}, + {:bcrypt_elixir, "~> 2.0"} + ] +end +``` + +Install the new dependencies: + +```bash +mix deps.get +``` + +### 2. Install Pow + +Run the Pow installer: + +```bash +mix pow.install +``` + +This command will: + +- Create a User schema +- Generate database migrations +- Update your router configuration +- Create Pow configuration + +### 3. Configure Pow Extensions + +Install Pow extensions for email confirmation and password reset: + +```bash +mix pow.extension.phoenix.gen.templates --extension PowResetPassword --extension PowEmailConfirmation +``` + +### 4. Update Pow Configuration + +Update your `config/config.exs` file with the complete Pow configuration: + +```elixir +# config/config.exs +config :elxrBB, :pow, + web_mailer_module: ElxrBBWeb, + web_module: ElxrBBWeb, + user: ElxrBB.Users.User, + repo: ElxrBB.Repo, + extensions: [PowResetPassword, PowEmailConfirmation], + controller_callbacks: Pow.Extension.Phoenix.ControllerCallbacks, + mailer_backend: ElxrBB.Pow.Mailer +``` + +### 5. Create the Pow Mailer + +Create the mailer file for handling authentication emails: + +```elixir +# lib/elxrBB_web/mails/pow/mailer.ex +defmodule ElxrBB.Pow.Mailer do + use Pow.Phoenix.Mailer + require Logger + + def cast(%{user: user, subject: subject, text: text, html: html, assigns: _assigns}) do + %{to: user.email, subject: subject, text: text, html: html} + end + + def process(email) do + # In development, we'll just log emails + # In production, you'd integrate with a real email service + Logger.debug("E-mail sent: #{inspect email}") + end +end +``` + +### 6. Update the User Schema + +The Pow installer creates a basic User schema. Let's enhance it with additional fields: + +```elixir +# lib/elxrBB/users/user.ex +defmodule ElxrBB.Users.User do + use Ecto.Schema + use Pow.Ecto.Schema + use Pow.Extension.Ecto.Schema, + extensions: [PowResetPassword, PowEmailConfirmation] + + schema "users" do + pow_user_fields() + + field :username, :string + field :bio, :string + + timestamps() + end + + def changeset(user_or_changeset, attrs) do + user_or_changeset + |> pow_changeset(attrs) + |> pow_extension_changeset(attrs) + |> Ecto.Changeset.cast(attrs, [:username, :bio]) + |> Ecto.Changeset.validate_length(:username, min: 3, max: 20) + |> Ecto.Changeset.validate_length(:bio, max: 500) + |> Ecto.Changeset.unique_constraint(:username) + end +end +``` + +### 7. Update the Users Context + +Create or update the Users context: + +```elixir +# lib/elxrBB/users/users.ex +defmodule ElxrBB.Users do + alias ElxrBB.Repo + alias ElxrBB.Users.User + + def get_user_by_email(email), do: Repo.get_by(User, email: email) + def get_user_by_id(id), do: Repo.get(User, id) + def get_user_by_username(username), do: Repo.get_by(User, username: username) + def list_users, do: Repo.all(User) + + def register_user(attrs) do + %User{} + |> User.changeset(attrs) + |> Repo.insert() + end + + def update_user(%User{} = user, attrs) do + user + |> User.changeset(attrs) + |> Repo.update() + end + + def delete_user(%User{} = user) do + Repo.delete(user) + end +end +``` + +### 8. Update the Router + +Ensure your router includes Pow routes: + +```elixir +# lib/elxrBB_web/router.ex +defmodule ElxrBBWeb.Router do + use ElxrBBWeb, :router + use Pow.Phoenix.Router + use Pow.Extension.Phoenix.Router, + extensions: [PowResetPassword, PowEmailConfirmation] + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {ElxrBBWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + # Pow authentication routes + scope "/" do + pipe_through :browser + + pow_routes() + pow_extension_routes() + end + + # Application routes + scope "/", ElxrBBWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Development routes + if Application.compile_env(:elxrBB, :dev_routes) do + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: ElxrBBWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end +``` + +### 9. Run Database Migrations + +```bash +# Run the migrations created by Pow +mix ecto.migrate +``` + +### 10. Update the Endpoint + +Ensure your endpoint includes the Pow session plug: + +```elixir +# lib/elxrBB_web/endpoint.ex +defmodule ElxrBBWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :elxrBB + + # ... existing configuration ... + + plug Plug.Session, @session_options + plug Pow.Plug.Session, otp_app: :elxrBB + plug ElxrBBWeb.Router +end +``` + +## Testing Authentication + +### 1. Start the Server + +```bash +mix phx.server +``` + +### 2. Test User Registration + +Visit [http://localhost:4000/registration/new](http://localhost:4000/registration/new) to test user registration. + +### 3. Test User Login + +Visit [http://localhost:4000/session/new](http://localhost:4000/session/new) to test user login. + +### 4. Check Email Logs + +In development, authentication emails are logged to the console. Look for log messages like: + +``` +[debug] E-mail sent: %{to: "user@example.com", subject: "Confirm your email", ...} +``` + +## Customizing Authentication Templates + +### Generate Custom Templates + +```bash +mix pow.phoenix.gen.templates +``` + +This creates customizable templates in `lib/elxrBB_web/controllers/pow/`. + +### Customize Registration Form + +You can customize the registration form to include additional fields: + +```html + +
    +
    +
    +

    + Create your account +

    +
    + + <.simple_form for={@changeset} action={~p"/registration"} method="post"> + <.input field={@changeset[:email]} type="email" label="Email" required /> + <.input field={@changeset[:password]} type="password" label="Password" required /> + <.input field={@changeset[:password_confirmation]} type="password" label="Confirm Password" required /> + <.input field={@changeset[:username]} type="text" label="Username" required /> + <.input field={@changeset[:bio]} type="textarea" label="Bio (optional)" /> + + <:actions> + <.button class="w-full">Register + + + +
    + <.link href={~p"/session/new"} class="text-indigo-600 hover:text-indigo-500"> + Already have an account? Sign in + +
    +
    +
    +``` + +## Adding Authentication to Your Application + +### Protect Routes + +To protect routes that require authentication, add a plug: + +```elixir +# lib/elxrBB_web/router.ex +pipeline :protected do + plug Pow.Plug.RequireAuthenticated, + error_handler: Pow.Phoenix.PlugErrorHandler +end + +scope "/", ElxrBBWeb do + pipe_through [:browser, :protected] + + # Protected routes go here + get "/dashboard", DashboardController, :index +end +``` + +### Access Current User + +In your controllers and LiveViews, you can access the current user: + +```elixir +# In a controller +def index(conn, _params) do + user = Pow.Plug.current_user(conn) + # ... rest of controller logic +end + +# In a LiveView +def mount(_params, _session, socket) do + user = Pow.Plug.current_user(socket.assigns[:__changed__][:conn]) + # ... rest of mount logic +end +``` + +## Next Steps + +In the next lesson, we'll implement the core forum functionality, including forums, topics, and replies. The authentication system we've built will be used to associate forum content with users. + +## Troubleshooting + +### Common Issues + +**"No route found" errors:** + +- Ensure `pow_routes()` and `pow_extension_routes()` are in your router +- Check that the Pow session plug is in your endpoint + +**Email confirmation not working:** + +- Verify the mailer is properly configured +- Check that email confirmation is enabled in the Pow config + +**Database errors:** + +- Run `mix ecto.migrate` to ensure all migrations are applied +- Check that the User schema includes all required Pow fields + +## Additional Resources + +- [Pow Documentation](https://hexdocs.pm/pow/README.html) +- [Pow Phoenix Integration](https://hexdocs.pm/pow/Phoenix.html) +- [Pow Extensions](https://hexdocs.pm/pow/Pow.Extension.html) diff --git a/lessons.exs b/lessons.exs new file mode 100644 index 0000000..4b51c77 --- /dev/null +++ b/lessons.exs @@ -0,0 +1,20 @@ +# Which state of the application each lesson describes. +# +# A lesson is a snapshot, not a view of HEAD: lesson 3 shows replies as a flat +# LiveView stream, and lesson 4 replaces that with a tree. Checking lesson 3 +# against the current application would report drift that is really just the +# tutorial doing its job. So each lesson names the ref in the application repo +# whose tree it describes. +# +# These are the commits that conclude each lesson on the modernization branch. +# Once that work lands on main and those commits are tagged `lesson-1` and so +# on, replace the SHAs with the tag names -- tags survive further history and +# read better here. A ref this checkout does not have falls back to the working +# tree, and the checker says so rather than passing quietly. + +%{ + "01-setting-up.md" => "97a4e68", + "02-user-authentication.md" => "0d4ae69", + "03-forum-functionality.md" => "3e900ff", + "04-threading-and-voting.md" => "93f6cad" +}