From 3609a9b4e42734557a32393d4be32c7e5ccf0fef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 04:07:39 +0000 Subject: [PATCH 1/6] Rewrite lessons 1-3 against the implemented application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lessons now describe code that exists in ephbaum/elxrBB rather than code that was planned. Every command and snippet was run. Lesson 1: Phoenix 1.8.13 on Elixir 1.20 / OTP 28. Uses the official elixir-lang install script, drops the Node.js step (1.8 builds assets with standalone esbuild and Tailwind binaries), and explains why the project is generated as `elxrbb --module ElxrBB` โ€” an OTP application name cannot be mixed case, which is what the 2023 attempt tripped over. `--live` is noted as redundant rather than removed, since the flag is still accepted. Lesson 2: rewritten around `mix phx.gen.auth` instead of Pow, and renamed to "User Accounts". Pow has had no release since January 2025 and predates the 1.8 layout and component conventions. The Pow draft moves to docs/archive/original-lessons/02-user-authentication-pow.md. The new lesson covers scopes, magic links and sudo mode, then adds elxrBB's own material: the username generator built from this repo's word lists, case-insensitive uniqueness via a functional index, and the profile form. Lesson 3: forums, topics and replies, replacing the planned flat `/topics` + `/replies` CRUD with the nested routes a forum actually needs. Covers the aggregate subquery that produces reply counts without an N+1 or a counter column, why route declaration order matters, why ownership is checked at mount and again on save, and how a stream-backed reply list has to track its own count. Each lesson calls out a bug that was hit while building: the nil-intolerant `String.trim/1` in `update_change/3`, the aggregate that loses its Ecto type crossing a subquery, and a username generator that could produce names its own schema rejected. STATUS.md now reports lessons and implementation separately, records the three deviations from the original plan, and lists what is next. The README gains a table of contents; the original account of the ChatGPT collaboration is kept as "Where this came from". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QKEb38aDmzRFVxFnM6Fe3X --- README.md | 31 +- STATUS.md | 96 +-- docs/01-setting-up.md | 258 ++++---- docs/02-user-authentication.md | 596 ++++++++++-------- docs/03-forum-functionality.md | 490 +++++++++----- docs/archive/README.md | 16 +- .../02-user-authentication-pow.md | 381 +++++++++++ 7 files changed, 1273 insertions(+), 595 deletions(-) create mode 100644 docs/archive/original-lessons/02-user-authentication-pow.md diff --git a/README.md b/README.md index c590dbb..5000d4e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,37 @@ # 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 | +| [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โ€“3 are written +against code that is actually in that repository and passes its test suite; +everything from lesson 4 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. + +## 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 +51,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 +61,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..2dd5e6c 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,56 +1,70 @@ # 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 +| # | Title | Lesson | Implemented | Notes | +|---|---|---|---|---| +| 1 | Setting Up the Environment | โœ… Written | โœ… Yes | Phoenix 1.8.13 on Elixir 1.20 / OTP 28 | +| 2 | User Accounts | โœ… Written | โœ… Yes | `phx.gen.auth`, not Pow โ€” see below | +| 3 | Forums, Topics and Replies | โœ… Written | โœ… Yes | Public reads, author-only writes | +| 4 | Threaded Replies and Voting | ๐Ÿ“‹ Outline only | โŒ No | Needs merging with lesson 5 | +| 5 | Sub-topics and Counts | ๐Ÿ“‹ Outline only | โŒ No | Redundant with lesson 4; merge | +| 6 | Private Messaging and Profiles | ๐Ÿ“‹ Outline only | โš ๏ธ Partial | Profiles (username, bio) shipped in lesson 2; PMs not started | +| 7โ€“16 | Roles, media, moderation, notifications, channels, a11y, tests, deploy | ๐Ÿ“‹ Outline only | โŒ No | See [00a-outline.md](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 +Legend: โœ… done ยท โš ๏ธ partial ยท ๐Ÿ“‹ planned ยท โŒ not started -## Implementation Progress +## What the application does today -### Application Repository -- **Repository**: https://github.com/ephbaum/elxrBB -- **Current Status**: Fresh start with documentation -- **Next Step**: Begin Lesson 1 implementation +- 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 and appended live. Authors can edit and + delete their own posts. -### Tutorial Repository -- **Repository**: https://github.com/ephbaum/elxrBB-tutorial -- **Current Status**: Lessons 1-2 cleaned, Lesson 3 planned -- **Next Step**: Complete remaining lesson plans +186 tests pass on `mix precommit`. -## Validation Process +## Deviations from the original plan -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 +**Lesson 2 uses `mix phx.gen.auth`, not Pow.** Pow's last release was January +2025 and it predates Phoenix 1.8's layout and component conventions; the +generator ships the maintained path and produces code the reader owns. The +original Pow lesson is preserved at +`docs/archive/original-lessons/02-user-authentication-pow.md`. -## Historical Context +**Lesson 3 uses nested routes, not flat CRUD.** The earlier draft listed +`/topics` and `/replies` index pages generated by `phx.gen.live`. A forum does +not work that way; replies are read inside their topic. The routes are +`/forums`, `/forums/:id`, `/topics/:id`, with forms hanging off them. -- **Original Lessons**: Archived in `docs/archive/original-lessons/` -- **Original Application**: Archived in `archive/initial-attempt` branch -- **Project Restart**: January 2025 +**Profiles arrived early.** Lesson 6 planned usernames and bios. They are part +of registration, so they shipped with lesson 2. Lesson 6 is reduced to private +messaging and avatars. -## Next Steps +## Next up -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 +1. Merge lessons 4 and 5 into one lesson on threaded replies, sub-topics and + voting, then implement it. +2. Rewrite lesson 6 around private messaging and avatar uploads. +3. Backfill lesson 14 (testing) โ€” the application is already tested, so the + lesson should describe what is there rather than propose something new. + +## 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/docs/01-setting-up.md b/docs/01-setting-up.md index a3b4d98..8371768 100644 --- a/docs/01-setting-up.md +++ b/docs/01-setting-up.md @@ -2,204 +2,194 @@ ## Overview -In this lesson, we'll set up a modern Elixir and Phoenix development environment and create the elxrBB forum application. We'll use the latest stable versions and best practices. +We install Elixir, Erlang/OTP and PostgreSQL, then generate the elxrBB +application with the Phoenix project generator. By the end you will have a +running Phoenix server on . -## Prerequisites +## What you need first -- Basic familiarity with command line -- Git installed -- A text editor or IDE (VS Code recommended) +- A command line, Git, and an editor. +- Roughly 2 GB of free disk for the toolchain and dependencies. -## Setting Up the Development Environment +## Versions this tutorial was written against -### Install Elixir +The reference application is built and tested with: -Visit the official Elixir installation page: https://elixir-lang.org/install.html +| | Version | +|---|---| +| Elixir | 1.20.4 | +| Erlang/OTP | 28 | +| Phoenix | 1.8.13 | +| PostgreSQL | 16 | -**Recommended approach using asdf (version manager):** +Phoenix 1.8 needs Elixir 1.15 or newer. Anything at or above that will work, +but if you are following along exactly, matching the table saves surprises. -```bash -# Install asdf (if not already installed) -git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0 - -# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.) -echo -e '\n. $HOME/.asdf/asdf.sh' >> ~/.bashrc -echo -e '\n. $HOME/.asdf/completions/asdf.bash' >> ~/.bashrc - -# Reload your shell -source ~/.bashrc - -# Install Erlang and Elixir -asdf plugin add erlang -asdf plugin add elixir -asdf install erlang latest -asdf install elixir latest -asdf global erlang latest -asdf global elixir latest -``` +## Installing Elixir and Erlang -**Verify installation:** +The official installer fetches precompiled builds and does not need a version +manager: ```bash -elixir --version -# Should show Elixir 1.15+ and Erlang/OTP 26+ +curl -fsSO https://elixir-lang.org/install.sh +sh install.sh elixir@1.20.4 otp@28.5.0.6 ``` -### Install Phoenix - -```bash -# Install Hex package manager -mix local.hex - -# Install Phoenix project generator -mix archive.install hex phx_new -``` +It prints two `export PATH=...` lines. Add them to your shell profile +(`~/.bashrc`, `~/.zshrc`) and reload it. -### Install PostgreSQL +If you prefer a version manager, [asdf](https://asdf-vm.com) or +[mise](https://mise.jdx.dev) both work; use their `erlang` and `elixir` +plugins. On macOS, `brew install elixir` installs a recent pair. -**Ubuntu/Debian:** +Verify: ```bash -sudo apt update -sudo apt install postgresql postgresql-contrib -sudo systemctl start postgresql -sudo systemctl enable postgresql +elixir --version +# Erlang/OTP 28 ... +# Elixir 1.20.4 (compiled with Erlang/OTP 28) ``` -**macOS (with Homebrew):** +> **Locale note.** If Elixir warns about `native name encoding of latin1`, your +> shell locale is not UTF-8. Set `LANG=C.UTF-8` (or your own UTF-8 locale), or +> export `ELIXIR_ERL_OPTIONS="+fnu"`. + +## Installing Hex, Rebar and the Phoenix generator ```bash -brew install postgresql -brew services start postgresql +mix local.hex --force +mix local.rebar --force +mix archive.install hex phx_new --force ``` -**Windows:** -Download and install from: https://www.postgresql.org/download/windows/ +`mix phx.new --version` should now report `Phoenix installer v1.8.13` or newer. -### Install Node.js +## Installing PostgreSQL -**Using asdf (recommended):** +**Debian/Ubuntu** ```bash -asdf plugin add nodejs -asdf install nodejs latest -asdf global nodejs latest +sudo apt update +sudo apt install postgresql postgresql-contrib +sudo service postgresql start ``` -**Or download from:** https://nodejs.org/ +**macOS** -## Creating the elxrBB Project +```bash +brew install postgresql@16 +brew services start postgresql@16 +``` -### Generate a New Phoenix Project +Phoenix's development configuration expects a `postgres` role with the password +`postgres`. On a fresh Debian/Ubuntu install: ```bash -# Create the project with LiveView and PostgreSQL -mix phx.new elxrBB --live --database postgres - -# Navigate to the project directory -cd elxrBB +sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'postgres';" ``` -### Configure the Database +## Node.js is not required -The project generator creates a `config/dev.exs` file with default PostgreSQL settings. Update it if needed: +Phoenix 1.8 builds assets with standalone [esbuild](https://esbuild.github.io) +and [Tailwind](https://tailwindcss.com) binaries that `mix` downloads for you. +There is no `package.json` and no `npm install`. You only need Node if you add a +JavaScript toolchain of your own later. -```elixir -# config/dev.exs -config :elxrBB, ElxrBB.Repo, - username: "postgres", - password: "postgres", - database: "elxrbb_dev", - hostname: "localhost", - show_sensitive_data_on_connection_error: true, - pool_size: 10 -``` - -### Create the Database +## Generating the project ```bash -# Create the development database -mix ecto.create - -# Run any existing migrations -mix ecto.migrate +mix phx.new elxrbb --module ElxrBB --database postgres +cd elxrbb ``` -### Install Dependencies and Build Assets +Two things about that command: -```bash -# Install Elixir dependencies -mix deps.get +- **`--live` is redundant.** LiveView is included by default in Phoenix 1.8. + The flag is still accepted and does nothing; older tutorials (including this + one, before it was rewritten) pass it out of habit. +- **`--module ElxrBB`.** The OTP application name has to be a lowercase atom, so + the project is `:elxrbb` on disk while the Elixir modules are namespaced + `ElxrBB` / `ElxrBBWeb`. The original 2023 attempt tried to name the project + `elxrBB` directly, which the generator rejects. -# Install and build frontend assets -mix assets.setup -mix assets.build -``` +Answer `Y` when it offers to fetch dependencies. -### Start the Development Server +## Database setup and first run ```bash -# Start the Phoenix server +mix setup # deps.get, ecto.create, ecto.migrate, seeds, assets mix phx.server ``` -Visit [http://localhost:4000](http://localhost:4000) in your browser. You should see the default Phoenix welcome page. +Visit . You should see the Phoenix welcome page. -## Project Structure Overview +`mix setup` is defined as an alias in `mix.exs`; look at it now, because it is +the one command you will run after every `git pull` for the rest of the series. -Your new Phoenix project has the following structure: +## What the generator gave you ``` -elxrBB/ -โ”œโ”€โ”€ assets/ # Frontend assets (CSS, JS) -โ”œโ”€โ”€ config/ # Configuration files +elxrbb/ +โ”œโ”€โ”€ assets/ # app.css, app.js โ€” built by esbuild + tailwind +โ”œโ”€โ”€ config/ # config.exs, dev.exs, test.exs, prod.exs, runtime.exs โ”œโ”€โ”€ lib/ -โ”‚ โ”œโ”€โ”€ elxrBB/ # Business logic (contexts) -โ”‚ โ”œโ”€โ”€ elxrBB_web/ # Web interface (controllers, views, templates) -โ”‚ โ””โ”€โ”€ elxrBB.ex # Main application module -โ”œโ”€โ”€ priv/ -โ”‚ โ”œโ”€โ”€ repo/ # Database migrations and seeds -โ”‚ โ””โ”€โ”€ static/ # Static assets -โ”œโ”€โ”€ test/ # Test files -โ”œโ”€โ”€ mix.exs # Project dependencies -โ””โ”€โ”€ README.md +โ”‚ โ”œโ”€โ”€ elxrbb/ # contexts: your business logic +โ”‚ โ”‚ โ”œโ”€โ”€ application.ex # the OTP supervision tree +โ”‚ โ”‚ โ”œโ”€โ”€ mailer.ex +โ”‚ โ”‚ โ””โ”€โ”€ repo.ex # the Ecto repository +โ”‚ โ”œโ”€โ”€ elxrbb_web/ # everything HTTP +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # core_components.ex, layouts +โ”‚ โ”‚ โ”œโ”€โ”€ controllers/ +โ”‚ โ”‚ โ”œโ”€โ”€ endpoint.ex # the plug pipeline +โ”‚ โ”‚ โ””โ”€โ”€ router.ex +โ”‚ โ””โ”€โ”€ elxrbb.ex +โ”œโ”€โ”€ priv/repo/migrations/ +โ”œโ”€โ”€ test/ +โ””โ”€โ”€ mix.exs ``` -## Key Phoenix Concepts - -- **Contexts**: Business logic modules (e.g., `ElxrBB.Users`, `ElxrBB.Forums`) -- **Controllers**: Handle HTTP requests -- **Views**: Render responses (HTML, JSON) -- **Templates**: HTML templates (using HEEx) -- **LiveView**: Real-time, interactive web interfaces -- **Ecto**: Database wrapper and query builder +The split that matters: `lib/elxrbb/` holds **contexts** โ€” plain modules that +own the data and the rules. `lib/elxrbb_web/` holds the web layer, which calls +into contexts and knows nothing about SQL. Every lesson from here adds to both +sides of that line, and keeping the line clean is most of what "idiomatic +Phoenix" means. -## Troubleshooting +Two more files worth opening now: -### Common Issues +- `lib/elxrbb_web/components/core_components.ex` โ€” the `<.button>`, + `<.input>`, `<.header>`, `<.table>` function components every template uses. + Phoenix 1.8 styles them with [daisyUI](https://daisyui.com) on top of + Tailwind 4. +- `lib/elxrbb_web/components/layouts.ex` โ€” ``, the wrapper every + page renders inside. -**Database connection errors:** +## Useful commands -- Ensure PostgreSQL is running -- Check username/password in `config/dev.exs` -- Verify database exists: `mix ecto.create` +```bash +mix test # run the test suite (it creates its own database) +mix format # format all code +mix precommit # compile with warnings-as-errors, format, test +iex -S mix phx.server # server with an attached REPL +``` -**Asset build errors:** +`mix precommit` is generated for you and is the check to run before every +commit in this series. -- Ensure Node.js is installed -- Run `mix assets.setup` to install frontend dependencies +## Troubleshooting -**Port already in use:** +**`connection refused` from Ecto** โ€” PostgreSQL is not running, or is not +listening where `config/dev.exs` expects. Check `pg_isready`. -- Change the port in `config/dev.exs` or kill the process using port 4000 +**`password authentication failed for user "postgres"`** โ€” set the password as +shown above, or edit `config/dev.exs` to match the role you actually have. -## Next Steps +**`Port 4000 in use`** โ€” something else is on it. `http: [port: 4001]` in +`config/dev.exs` moves the server. -In the next lesson, we'll implement user authentication using the Pow library, which provides a robust, production-ready authentication system. +**Live reload complains about `inotify-tools`** โ€” a development-only +convenience; install `inotify-tools` on Linux or ignore it. -## Additional Resources +## Next -- [Phoenix Documentation](https://hexdocs.pm/phoenix/overview.html) -- [Elixir Documentation](https://hexdocs.pm/elixir/Kernel.html) -- [Ecto Documentation](https://hexdocs.pm/ecto/Ecto.html) -- [LiveView Documentation](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html) +[Lesson 2](02-user-authentication.md) adds accounts: registration, login, +email confirmation, and the animal-themed usernames elxrBB hands out. diff --git a/docs/02-user-authentication.md b/docs/02-user-authentication.md index 83c48a2..63ec883 100644 --- a/docs/02-user-authentication.md +++ b/docs/02-user-authentication.md @@ -1,381 +1,435 @@ -# Lesson 2: User Authentication with Pow +# Lesson 2: User Accounts ## 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. +We add accounts to elxrBB: registration, login, email confirmation, password +and email changes โ€” and the animal-themed usernames the forum is known for. -## Why Pow? +## Why not Pow? -Pow is a batteries-included authentication library for Phoenix that provides: +Earlier drafts of this tutorial used [Pow](https://hexdocs.pm/pow). We do not, +for two reasons: -- User registration and login -- Email confirmation -- Password reset functionality -- Session management -- Extensible architecture -- Production-ready security features +1. Pow's last release was January 2025, and it predates the Phoenix 1.8 layout + and component conventions. Its generated templates do not match the rest of + the app, so you spend the lesson fighting styling instead of learning auth. +2. Phoenix now ships `mix phx.gen.auth`, which is maintained alongside the + framework and generates code you own and can read. -## Adding Pow to the Project +The Pow version of this lesson is preserved at +[`docs/archive/original-lessons/02-user-authentication-pow.md`](archive/original-lessons/02-user-authentication-pow.md) +if you want to compare. -### 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: +## Running the generator ```bash +mix phx.gen.auth Accounts User users --live mix deps.get +mix ecto.migrate ``` -### 2. Install Pow +`--live` gives you LiveView-based registration and login pages rather than +controller-rendered ones, which is what you want when the rest of the app is +LiveView. -Run the Pow installer: +That single command writes a lot of code. It is *your* code โ€” read it, change +it. The pieces worth understanding before we build on top: -```bash -mix pow.install -``` +| File | What it does | +|---|---| +| `lib/elxrbb/accounts.ex` | The context. Registration, tokens, email and password changes. | +| `lib/elxrbb/accounts/user.ex` | The schema and its changesets โ€” one changeset per operation, not one for everything. | +| `lib/elxrbb/accounts/user_token.ex` | Session, magic-link, confirmation and email-change tokens. | +| `lib/elxrbb/accounts/scope.ex` | `%Scope{user: user}` โ€” what the web layer is allowed to see. | +| `lib/elxrbb_web/user_auth.ex` | Plugs and `on_mount` hooks: `fetch_current_scope_for_user`, `require_authenticated`, `mount_current_scope`. | +| `lib/elxrbb_web/live/user_live/*` | Registration, login, confirmation and settings LiveViews. | -This command will: +Run `mix test`. The generator writes its own tests, and they should all pass +before you change anything. -- Create a User schema -- Generate database migrations -- Update your router configuration -- Create Pow configuration +### Three concepts worth pausing on -### 3. Configure Pow Extensions +**Scopes.** The generator does not put a bare `current_user` in your assigns. +It puts a `%Scope{}` there. Everything downstream โ€” templates, LiveViews, +context functions โ€” takes the scope, so when elxrBB later grows organizations +or roles, there is one place to widen. `@current_scope` is `nil` for a +visitor who is not signed in, which is exactly the check your templates want. -Install Pow extensions for email confirmation and password reset: +**Magic links.** Registration asks only for an email address. The user gets a +link, clicks it, and is signed in and confirmed. A password is optional and set +later from the settings page. Fewer fields, no password reset flow to build. -```bash -mix pow.extension.phoenix.gen.templates --extension PowResetPassword --extension PowEmailConfirmation -``` +**Sudo mode.** Changing your email or password requires having authenticated +recently (20 minutes by default). `on_mount {ElxrBBWeb.UserAuth, +:require_sudo_mode}` on the settings LiveView enforces it. -### 4. Update Pow Configuration +### Reading the emails in development -Update your `config/config.exs` file with the complete Pow configuration: +Confirmation and magic-link emails are captured, not sent. Visit +. -```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 -``` +## Adding elxrBB's own fields -### 5. Create the Pow Mailer +The generated user has an email and a password. A forum needs a display name +and a bio. -Create the mailer file for handling authentication emails: +### The migration ```elixir -# lib/elxrBB_web/mails/pow/mailer.ex -defmodule ElxrBB.Pow.Mailer do - use Pow.Phoenix.Mailer - require Logger +# priv/repo/migrations/..._add_profile_fields_to_users.exs +defmodule ElxrBB.Repo.Migrations.AddProfileFieldsToUsers do + use Ecto.Migration + + def up do + alter table(:users) do + add :username, :string, size: 30 + add :bio, :text + end + + execute "UPDATE users SET username = 'user' || id WHERE username IS NULL" + + alter table(:users) do + modify :username, :string, size: 30, null: false + end - def cast(%{user: user, subject: subject, text: text, html: html, assigns: _assigns}) do - %{to: user.email, subject: subject, text: text, html: html} + create unique_index(:users, ["lower(username)"], name: :users_lower_username_index) 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}") + def down do + drop index(:users, ["lower(username)"], name: :users_lower_username_index) + + alter table(:users) do + remove :username + remove :bio + end end end ``` -### 6. Update the User Schema +Three things to notice. -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] +**Add nullable, backfill, then constrain.** You cannot add a `NOT NULL` column +to a table that already has rows. Even on a project with no users yet, writing +the migration this way is the habit you want. - schema "users" do - pow_user_fields() +**`up`/`down`, not `change`.** `change` cannot infer how to reverse an +`execute`, so the reversible pair is written out by hand. - field :username, :string - field :bio, :string +**The unique index is on `lower(username)`, not on `username`.** We want +`Aardvark` to display with its capital A but still collide with `aardvark`. +A functional index does that without the `citext` extension. - timestamps() - end +### The schema - 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 +```elixir +# lib/elxrbb/accounts/user.ex +schema "users" do + field :email, :string + # ... generated fields ... + field :username, :string + field :bio, :string + + timestamps(type: :utc_datetime) end -``` - -### 7. Update the Users Context -Create or update the Users context: +@username_format ~r/^[A-Za-z0-9_-]+$/ +@username_max_length ElxrBB.Accounts.Username.max_length() -```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 profile_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:username, :bio]) + |> validate_username(opts) + |> validate_length(:bio, max: 500) +end - def delete_user(%User{} = user) do - Repo.delete(user) +defp validate_username(changeset, opts) do + changeset = + changeset + |> update_change(:username, &trim/1) + |> validate_required([:username]) + |> validate_length(:username, min: 3, max: @username_max_length) + |> validate_format(:username, @username_format, + message: "may only contain letters, numbers, underscores and hyphens" + ) + |> unique_constraint(:username, name: :users_lower_username_index) + + if Keyword.get(opts, :validate_unique, true) do + validate_username_available(changeset) + else + changeset end end + +defp trim(nil), do: nil +defp trim(value) when is_binary(value), do: String.trim(value) ``` -### 8. Update the Router +`trim/1` has to accept `nil`. `update_change/3` runs whenever the field is +cast โ€” including when it is cast to `nil` โ€” and `String.trim(nil)` raises a +`FunctionClauseError` that surfaces as a 500 instead of a validation error. +This is the kind of bug a test catches and a manual click-through does not. -Ensure your router includes Pow routes: +The `:validate_unique` option follows the pattern the generator already uses +for email: live validation on every keystroke should not hit the database, but +the final save should. The database index is the actual authority; the query +just produces a nicer error. -```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 +### The Users context additions - pipeline :api do - plug :accepts, ["json"] - end +```elixir +# lib/elxrbb/accounts.ex +def register_user(attrs) do + %User{} + |> User.email_changeset(attrs) + |> User.profile_changeset(Map.put_new(normalize(attrs), "username", generate_username())) + |> Repo.insert() +end - # Pow authentication routes - scope "/" do - pipe_through :browser +def generate_username(attempts \\ 5) - pow_routes() - pow_extension_routes() - end +def generate_username(0), + do: Username.generate() <> Integer.to_string(System.unique_integer([:positive])) - # Application routes - scope "/", ElxrBBWeb do - pipe_through :browser +def generate_username(attempts) do + candidate = Username.generate() - get "/", PageController, :home + if username_taken?(candidate) do + generate_username(attempts - 1) + else + candidate end +end - # Development routes - if Application.compile_env(:elxrBB, :dev_routes) do - import Phoenix.LiveDashboard.Router +def username_taken?(username) do + Repo.exists?( + from(u in User, where: fragment("lower(?)", u.username) == ^String.downcase(username)) + ) +end - scope "/dev" do - pipe_through :browser +def get_user_by_username(username) when is_binary(username) do + Repo.one( + from(u in User, where: fragment("lower(?)", u.username) == ^String.downcase(username)) + ) +end - live_dashboard "/dashboard", metrics: ElxrBBWeb.Telemetry - forward "/mailbox", Plug.Swoosh.MailboxPreview - end - end +def update_user_profile(%User{} = user, attrs) do + user + |> User.profile_changeset(attrs) + |> Repo.update() +end + +def change_user_profile(%User{} = user, attrs \\ %{}, opts \\ []) do + User.profile_changeset(user, attrs, opts) end ``` -### 9. Run Database Migrations +`Map.put_new` means a caller who supplies a username keeps it; everyone else +gets one assigned. Registration stays a single-field form. + +## The username generator + +elxrBB names new accounts after a gerund verb and an animal: +`ChortlingAardvark`, `WanderingZebu`, `PiningElephantShrew`. The word lists +live in this repository under `data/`; copy them into the application: ```bash -# Run the migrations created by Pow -mix ecto.migrate +mkdir -p priv/data +cp ../elxrBB-tutorial/data/Animals.txt priv/data/animals.txt +cp ../elxrBB-tutorial/data/GerundVerbs.txt priv/data/gerund_verbs.txt ``` -### 10. Update the Endpoint +```elixir +# lib/elxrbb/accounts/username.ex +defmodule ElxrBB.Accounts.Username do + @max_length 30 + + @priv_data Path.expand("../../../priv/data", __DIR__) + @animals_path Path.join(@priv_data, "animals.txt") + @verbs_path Path.join(@priv_data, "gerund_verbs.txt") + + @external_resource @animals_path + @external_resource @verbs_path + + load_words = fn path -> + path + |> File.read!() + |> String.split("\n", trim: true) + |> Enum.map(&String.replace(&1, ~r/[^A-Za-z]/, "")) + |> Enum.reject(&(&1 == "")) + |> Enum.map(fn <> -> String.upcase(<>) <> rest end) + |> Enum.uniq() + |> Enum.sort() + end -Ensure your endpoint includes the Pow session plug: + @animals load_words.(@animals_path) + @verbs load_words.(@verbs_path) -```elixir -# lib/elxrBB_web/endpoint.ex -defmodule ElxrBBWeb.Endpoint do - use Phoenix.Endpoint, otp_app: :elxrBB + @verbs_by_length @verbs |> Enum.sort_by(&byte_size/1) |> List.to_tuple() + @shortest_verb @verbs |> Enum.map(&byte_size/1) |> Enum.min() - # ... existing configuration ... + @verbs_within Map.new(0..@max_length, fn budget -> + {budget, Enum.count(@verbs, &(byte_size(&1) <= budget))} + end) - plug Plug.Session, @session_options - plug Pow.Plug.Session, otp_app: :elxrBB - plug ElxrBBWeb.Router -end -``` + @usable_animals @animals + |> Enum.filter(&(byte_size(&1) + @shortest_verb <= @max_length)) + |> List.to_tuple() -## Testing Authentication + def generate do + animal = elem(@usable_animals, :rand.uniform(tuple_size(@usable_animals)) - 1) + fitting_verbs = Map.fetch!(@verbs_within, @max_length - byte_size(animal)) + verb = elem(@verbs_by_length, :rand.uniform(fitting_verbs) - 1) -### 1. Start the Server + verb <> animal + end -```bash -mix phx.server + def max_length, do: @max_length +end ``` -### 2. Test User Registration - -Visit [http://localhost:4000/registration/new](http://localhost:4000/registration/new) to test user registration. +Three techniques in that module are worth taking away. -### 3. Test User Login +**Compile-time loading.** The `load_words` anonymous function runs while the +module compiles; `@animals` and `@verbs` are baked into the BEAM file. A note +on why it is an anonymous function and not a `defp`: a module cannot call its +own functions in its own body, because it does not exist yet. +`@external_resource` tells `mix` to recompile when the text files change. -Visit [http://localhost:4000/session/new](http://localhost:4000/session/new) to test user login. +**Cleaning the input.** The animal list has entries like `Adelie Penguin` and +`American Staffordshire Terrier`. Stripping non-letters turns them into +`AdeliePenguin` โ€” collapsed, not discarded. -### 4. Check Email Logs +**Respecting your own validation.** The naive version โ€” pick any verb, pick any +animal โ€” can produce `MisunderstandingAmericanStaffordshireTerrier`, which is +44 characters and fails the 30-character rule the schema enforces. Registration +then blows up on a name the app itself chose. -In development, authentication emails are logged to the console. Look for log messages like: +The fix keeps generation O(1). Verbs are sorted shortest-first in a tuple, so +"every verb that fits in N bytes" is a prefix of that tuple, and +`@verbs_within` says how long the prefix is. Pick an animal, look up the +budget, index into the tuple. 544,116 valid pairs, all of them legal. -``` -[debug] E-mail sent: %{to: "user@example.com", subject: "Confirm your email", ...} -``` +This is the general lesson: **generated data has to satisfy the same +constraints as user input.** Test it the same way, too. -## Customizing Authentication Templates +## Showing profiles in the UI -### Generate Custom Templates +### The settings page -```bash -mix pow.phoenix.gen.templates -``` +Add a profile form to `lib/elxrbb_web/live/user_live/settings.ex` above the +existing email form: -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 - -
-
-
+```heex +<.form for={@profile_form} id="profile_form" phx-submit="update_profile" phx-change="validate_profile"> + <.input field={@profile_form[:username]} type="text" label="Username" required /> + <.input field={@profile_form[:bio]} type="textarea" label="Bio" maxlength="500" /> + <.button variant="primary" phx-disable-with="Saving...">Save Profile + ``` -## Adding Authentication to Your Application +```elixir +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() + + {: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 +
  • + {@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..929915c 100644 --- a/docs/03-forum-functionality.md +++ b/docs/03-forum-functionality.md @@ -1,198 +1,406 @@ -# 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 +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 +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 + + timestamps(type: :utc_datetime) +end -Replies (Comments) -โ”œโ”€โ”€ id -โ”œโ”€โ”€ body (text) -โ”œโ”€โ”€ topic_id (references topics) -โ”œโ”€โ”€ user_id (references users) -โ”œโ”€โ”€ created_at -โ””โ”€โ”€ updated_at +create index(:topics, [:forum_id, "inserted_at DESC"]) +create index(:topics, [:user_id]) ``` -### 2. Phoenix Generators +```elixir +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]) +``` + +**`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. -# Create Topic context -mix phx.gen.live Forums Topic topics title:string body:text forum_id:references:forums user_id:references:users +## 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 -# Create Reply context -mix phx.gen.live Forums Reply replies body:text topic_id:references:topics user_id:references:users + # 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 + +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 +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.** + +```elixir +def topic_owner?(%Topic{user_id: user_id}, %User{id: user_id}), do: true +def topic_owner?(_topic, _user), do: false +``` -# Reply functions -- list_replies_by_topic/1 -- get_reply!/1 -- create_reply/2 (attrs, user) -- update_reply/2 -- delete_reply/1 +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 +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 ``` -### 5. LiveView Implementation +Points of interest: -We'll create LiveViews for: +- `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. -- Forum listing page -- Topic listing page (by forum) -- Individual topic view with replies -- Topic creation form -- Reply creation form +## The web layer -### 6. Router Configuration +### Routes ```elixir +# 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 +<.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 +defp save_topic(socket, :edit, topic_params) do + %{topic: topic, current_scope: %{user: user}} = socket.assigns + + if Forums.topic_owner?(topic, user) do + # ... update ... + else + # ... refuse ... + end +end +``` -- 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 +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`. -### 8. User Associations +### Streams for the reply list -- 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 +`TopicLive.Show` keeps replies in a LiveView stream rather than an assign, so +posting a reply appends one `
  • ` instead of re-rendering the thread: -## Implementation Steps +```elixir +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 +``` -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** +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. -## Dependencies +The markup needs `phx-update="stream"` on the container and an `id` on each +child: -This lesson builds on: +```heex +
      +
    • + ... +
    • +
    +``` -- Lesson 1: Phoenix setup and project structure -- Lesson 2: User authentication system +## A bug worth repeating -## Next Lessons +Both the forum and the topic changesets trim whitespace: -This lesson provides the foundation for: +```elixir +|> 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 +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. -- Lesson 4: Threaded replies and voting system -- Lesson 5: Advanced forum features -- Lesson 6: User profiles and private messaging +## Seeds -## Current Status +`priv/repo/seeds.exs` creates five starter forums, and skips ones that already +exist so it is safe to re-run: + +```elixir +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 +``` + +## Testing + +Fixtures take their associations as options, creating them on demand: + +```elixir +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 "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 adds threaded replies and voting on top of this structure. diff --git a/docs/archive/README.md b/docs/archive/README.md index f11b6cb..33ff1b2 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -41,12 +41,20 @@ 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` โ€” the Pow-based version of lesson 2. It was + written for the restart and is more coherent than the 2023 original, but Pow + has not had a release since January 2025 and predates the Phoenix 1.8 + conventions. Lesson 2 now uses `mix phx.gen.auth`. Kept for comparison. + ## 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` โ€” `phx.gen.auth`, 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) From f709995222ec24ca38b1f5d7a65e774fb5d66182 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 05:18:25 +0000 Subject: [PATCH 2/6] Frame lesson 2 around what Phoenix ships today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lesson 2 opened by arguing against a library rather than explaining the one it uses. It now states the choice โ€” `mix phx.gen.auth` โ€” and moves on. The outline entry, the status note and the archive pointer follow suit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QKEb38aDmzRFVxFnM6Fe3X --- STATUS.md | 10 ++++------ docs/00a-outline.md | 10 +++++----- docs/02-user-authentication.md | 20 ++++++++------------ docs/archive/README.md | 9 ++++----- 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/STATUS.md b/STATUS.md index 2dd5e6c..2ae2349 100644 --- a/STATUS.md +++ b/STATUS.md @@ -9,7 +9,7 @@ Application repository: | # | Title | Lesson | Implemented | Notes | |---|---|---|---|---| | 1 | Setting Up the Environment | โœ… Written | โœ… Yes | Phoenix 1.8.13 on Elixir 1.20 / OTP 28 | -| 2 | User Accounts | โœ… Written | โœ… Yes | `phx.gen.auth`, not Pow โ€” see below | +| 2 | User Accounts | โœ… Written | โœ… Yes | Built on `mix phx.gen.auth` | | 3 | Forums, Topics and Replies | โœ… Written | โœ… Yes | Public reads, author-only writes | | 4 | Threaded Replies and Voting | ๐Ÿ“‹ Outline only | โŒ No | Needs merging with lesson 5 | | 5 | Sub-topics and Counts | ๐Ÿ“‹ Outline only | โŒ No | Redundant with lesson 4; merge | @@ -34,11 +34,9 @@ Legend: โœ… done ยท โš ๏ธ partial ยท ๐Ÿ“‹ planned ยท โŒ not started ## Deviations from the original plan -**Lesson 2 uses `mix phx.gen.auth`, not Pow.** Pow's last release was January -2025 and it predates Phoenix 1.8's layout and component conventions; the -generator ships the maintained path and produces code the reader owns. The -original Pow lesson is preserved at -`docs/archive/original-lessons/02-user-authentication-pow.md`. +**Lesson 2 is built on `mix phx.gen.auth`.** The outline named a third-party +library; Phoenix now ships its own generator, which is maintained with the +framework and produces code the reader owns. **Lesson 3 uses nested routes, not flat CRUD.** The earlier draft listed `/topics` and `/replies` index pages generated by `phx.gen.live`. A forum does diff --git a/docs/00a-outline.md b/docs/00a-outline.md index 9626f97..45408df 100644 --- a/docs/00a-outline.md +++ b/docs/00a-outline.md @@ -10,11 +10,11 @@ - Installing Phoenix - Creating a new Phoenix project -## Lesson 2: User Authentication with Pow -- Adding Pow to your project -- Configuring Pow for email-based authentication -- Customizing user registration with a random username -- Testing user authentication +## Lesson 2: User Accounts +- Generating authentication with `mix phx.gen.auth` +- Scopes, magic-link login and email confirmation +- Assigning each new account a random username +- User profiles: usernames and biographies ## Lesson 3: Implementing Basic Forum Functionality - Creating a new context for forums diff --git a/docs/02-user-authentication.md b/docs/02-user-authentication.md index 63ec883..54bbb9e 100644 --- a/docs/02-user-authentication.md +++ b/docs/02-user-authentication.md @@ -5,20 +5,16 @@ We add accounts to elxrBB: registration, login, email confirmation, password and email changes โ€” and the animal-themed usernames the forum is known for. -## Why not Pow? +## Authentication in Phoenix 1.8 -Earlier drafts of this tutorial used [Pow](https://hexdocs.pm/pow). We do not, -for two reasons: +Phoenix ships `mix phx.gen.auth`, which writes a complete email-based +authentication system into your application: registration, magic-link login, +email confirmation, password and email changes, and session management. It is +maintained alongside the framework, its output matches the layouts and +components the rest of the app uses, and โ€” most usefully for a tutorial โ€” the +code lands in your repository where you can read and change it. -1. Pow's last release was January 2025, and it predates the Phoenix 1.8 layout - and component conventions. Its generated templates do not match the rest of - the app, so you spend the lesson fighting styling instead of learning auth. -2. Phoenix now ships `mix phx.gen.auth`, which is maintained alongside the - framework and generates code you own and can read. - -The Pow version of this lesson is preserved at -[`docs/archive/original-lessons/02-user-authentication-pow.md`](archive/original-lessons/02-user-authentication-pow.md) -if you want to compare. +That is what we use. ## Running the generator diff --git a/docs/archive/README.md b/docs/archive/README.md index 33ff1b2..a63f84c 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -43,17 +43,16 @@ The original lessons were archived because: ## Also archived here -- `02-user-authentication-pow.md` โ€” the Pow-based version of lesson 2. It was - written for the restart and is more coherent than the 2023 original, but Pow - has not had a release since January 2025 and predates the Phoenix 1.8 - conventions. Lesson 2 now uses `mix phx.gen.auth`. Kept for comparison. +- `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 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` โ€” `phx.gen.auth`, usernames and bios +- `02-user-authentication.md` โ€” user accounts, usernames and bios - `03-forum-functionality.md` โ€” forums, topics and replies ## Learning Value From bbcc2d202b67f802a1967a07791775ab73df43f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 05:20:29 +0000 Subject: [PATCH 3/6] Revise the lesson plan against the implemented application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audits the original sixteen-lesson outline now that lessons 1-3 exist. The spine holds; several specifics do not. - Lessons 4 and 5 describe one feature โ€” a reply with a parent_id โ€” and are merged. - Real-time moves from lesson 11 to lesson 5 and switches from Phoenix Channels to PubSub. A LiveView receives broadcasts in handle_info/2; Channels are for clients that are not LiveViews. Leaving it at lesson 11 also means six lessons where a posted reply is invisible to everyone but its author. - Pagination and search were absent from the plan entirely. Every list query in the application is unbounded today, which is the largest gap between finishing the tutorial and having a working forum. New lesson 6. - Testing moves earlier and narrows: the application has had tests since lesson 2, so lesson 13 covers what we have not used rather than introducing the idea. - The animal-names lesson is dropped. Lesson 2 loads those word lists at compile time; putting them in a table and exposing them to JavaScript adds nothing. The JS-hook material moves to lesson 8 as a Markdown preview. - Payments, SMS and browser push become appendices C and D. A step needing a vendor account, a card and a public webhook endpoint is not one most readers can take, and the roles work it was bundled with is real. - Old lesson 6 splits: profiles already shipped in lesson 2, avatars and private messaging become lesson 7, and file storage sits behind a behaviour rather than naming one vendor in the title. - Rich text narrows to Markdown alone, with sanitization as the point of the lesson rather than a footnote. STATUS.md now lists the application's known gaps โ€” no pagination, no real-time, no search, plain-text bodies, unrestricted forum management, no rate limiting โ€” so they read as scheduled rather than overlooked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QKEb38aDmzRFVxFnM6Fe3X --- STATUS.md | 72 +++++---- docs/00a-outline.md | 306 +++++++++++++++++++++++++++------------ docs/00b-introduction.md | 4 +- 3 files changed, 257 insertions(+), 125 deletions(-) diff --git a/STATUS.md b/STATUS.md index 2ae2349..26b3676 100644 --- a/STATUS.md +++ b/STATUS.md @@ -6,17 +6,28 @@ Application repository: ## Lessons -| # | Title | Lesson | Implemented | Notes | -|---|---|---|---|---| -| 1 | Setting Up the Environment | โœ… Written | โœ… Yes | Phoenix 1.8.13 on Elixir 1.20 / OTP 28 | -| 2 | User Accounts | โœ… Written | โœ… Yes | Built on `mix phx.gen.auth` | -| 3 | Forums, Topics and Replies | โœ… Written | โœ… Yes | Public reads, author-only writes | -| 4 | Threaded Replies and Voting | ๐Ÿ“‹ Outline only | โŒ No | Needs merging with lesson 5 | -| 5 | Sub-topics and Counts | ๐Ÿ“‹ Outline only | โŒ No | Redundant with lesson 4; merge | -| 6 | Private Messaging and Profiles | ๐Ÿ“‹ Outline only | โš ๏ธ Partial | Profiles (username, bio) shipped in lesson 2; PMs not started | -| 7โ€“16 | Roles, media, moderation, notifications, channels, a11y, tests, deploy | ๐Ÿ“‹ Outline only | โŒ No | See [00a-outline.md](docs/00a-outline.md) | - -Legend: โœ… done ยท โš ๏ธ partial ยท ๐Ÿ“‹ planned ยท โŒ not started +Numbering follows the revised [outline](docs/00a-outline.md). + +| # | 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 | ๐Ÿšง Next | ๐Ÿšง Next | +| 5 | Real-Time Updates with PubSub | ๐Ÿ“‹ Outlined | โŒ No | +| 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 | + +Legend: โœ… done ยท ๐Ÿšง in progress ยท โš ๏ธ partial ยท ๐Ÿ“‹ planned ยท โŒ not started ## What the application does today @@ -32,28 +43,31 @@ Legend: โœ… done ยท โš ๏ธ partial ยท ๐Ÿ“‹ planned ยท โŒ not started 186 tests pass on `mix precommit`. -## Deviations from the original plan - -**Lesson 2 is built on `mix phx.gen.auth`.** The outline named a third-party -library; Phoenix now ships its own generator, which is maintained with the -framework and produces code the reader owns. +## Known gaps in the application -**Lesson 3 uses nested routes, not flat CRUD.** The earlier draft listed -`/topics` and `/replies` index pages generated by `phx.gen.live`. A forum does -not work that way; replies are read inside their topic. The routes are -`/forums`, `/forums/:id`, `/topics/:id`, with forms hanging off them. +These are true of the code today and are scheduled, not forgotten: -**Profiles arrived early.** Lesson 6 planned usernames and bios. They are part -of registration, so they shipped with lesson 2. Lesson 6 is reduced to private -messaging and avatars. +- **No pagination.** `list_topics/1` and `list_replies/1` return everything. + Lesson 6. +- **No real-time.** A posted reply 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 up +## Deviations from the original plan -1. Merge lessons 4 and 5 into one lesson on threaded replies, sub-topics and - voting, then implement it. -2. Rewrite lesson 6 around private messaging and avatar uploads. -3. Backfill lesson 14 (testing) โ€” the application is already tested, so the - lesson should describe what is there rather than propose something new. +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 diff --git a/docs/00a-outline.md b/docs/00a-outline.md index 45408df..27713c7 100644 --- a/docs/00a-outline.md +++ b/docs/00a-outline.md @@ -1,107 +1,225 @@ # elxrBB Tutorial Series Outline +Sixteen planned lessons became fifteen plus four optional appendices. See +[Revisions to this plan](#revisions-to-this-plan) at the bottom for what +changed and why. + +Status of each lesson is tracked in [STATUS.md](../STATUS.md). + ## Introduction -- Overview of elxrBB + +- [Overview of elxrBB](00b-introduction.md) - Introducing Elixir and Phoenix -- System Requirements and Setup +- System requirements and setup + +## Lesson 1: Setting Up the Environment โœ… -## Lesson 1: Setting Up the Elixir and Phoenix Environment -- Installing Elixir -- Installing Phoenix -- Creating a new Phoenix project +- Installing Elixir, Erlang/OTP and PostgreSQL +- Installing the Phoenix project generator +- Generating the elxrBB project and reading what it produced + +## Lesson 2: User Accounts โœ… -## Lesson 2: User Accounts - Generating authentication with `mix phx.gen.auth` - Scopes, magic-link login and email confirmation - Assigning each new account a random username - User profiles: usernames and biographies -## Lesson 3: Implementing Basic Forum Functionality -- Creating a new context for forums -- Defining the Topic and Reply schemas -- CRUD operations for topics and replies -- Updating the router, controller, and views -- Creating templates for topics and replies - -## Lesson 4: Implementing Threaded Replies and Upvoting/Downvoting -- Adding threaded replies to topics -- Creating upvote and downvote functionality -- Updating the UI to display threaded replies and votes - -## Lesson 5: Implementing Sub-Topics and Displaying Sub-Topic Counts -- Adding sub-topic functionality to threaded replies -- Updating the UI to display sub-topic counts -- Providing a view for sub-topics - -## Lesson 6: Adding Private Messaging and User Profiles -- Implementing private messaging between users -- Creating user profiles with biographies and preferred names -- Implementing an avatar system with file uploads -- Configuring file storage with Digital Ocean Block Storage - -## Lesson 7: Implementing User Roles and a VIP Section -- Defining user roles and permissions -- Creating a VIP section for pro users -- Implementing subscription-based access -- Configuring payment processing - -## Lesson 8: Adding Media Upload Capabilities and Post Formatting Options -- Implementing image uploads for posts -- Adding post formatting options: Markdown, BBCode, and WYSIWYG editor -- Updating the UI to display formatted posts - -## Lesson 9: Implementing User Audit Trails and Admin Features -- Creating a user audit trail -- Handling post edits and deletions by moderators -- Implementing soft deletes and flagging -- Creating an admin dashboard - -## Lesson 10: Adding a Notification System -- Implementing email notifications -- Integrating browser notifications -- Configuring SMS/Voice notifications with Twilio for Pro Users - -## Lesson 11: Integrating Real-Time Updates with Phoenix Channels -- Overview of Phoenix Channels -- Setting up channels for topics and replies -- Implementing real-time updates in the UI - -## Lesson 12: Populating the Animal Names Database and Integrating with JavaScript -- Creating the database table for animal names -- Importing animal names from an external source -- Integrating with JavaScript to provide random animal names - -## Lesson 13: Implementing Accessible Design (a11y) -- Overview of accessibility in web applications -- Best practices for accessible design in elxrBB -- Implementing and testing accessibility features - -## Lesson 14: Writing Tests for Your Application -- Introduction to testing in Elixir and Phoenix -- Writing tests for elxrBB's main features -- Running tests and interpreting test results - -## Lesson 15: Deploying Your elxrBB Application -- Preparing your application for deployment -- Deploying elxrBB to a server or cloud platform -- Configuring the production environment and ensuring security - -## Lesson 16: Customizing and Extending elxrBB -- Overview of customization options for elxrBB -- Implementing custom features and extensions -- Contributing to the elxrBB open-source project +## Lesson 3: Forums, Topics and Replies โœ… + +- The Forums context: forums, topics, replies +- Schemas, associations and cascading deletes +- Public reads, authenticated writes, author-only edits +- LiveViews for browsing, posting and replying +- Counting replies without an N+1 or a counter column + +## Lesson 4: Threaded Replies, Sub-Topics and Voting + +- Self-referencing replies: `parent_id` and a nested tree +- Rendering a thread recursively without an N+1 +- Sub-topic counts on every reply +- Upvotes and downvotes on topics and replies +- One vote per user per post, changeable and revocable +- Sorting a thread by score + +## Lesson 5: Real-Time Updates with PubSub + +- Why a LiveView app broadcasts through `Phoenix.PubSub`, not Channels +- Subscribing a LiveView to a topic and handling `handle_info/2` +- Broadcasting from the context, so every writer publishes +- Reconciling a broadcast with a LiveView stream +- Presence: who else is reading this thread +- Where Channels *are* the right tool (non-LiveView clients, mobile, an API) + +## Lesson 6: Pagination and Search + +- Why every unbounded list query is a latent outage +- Keyset pagination, and why `OFFSET` degrades +- Paginating topics and replies in LiveView +- PostgreSQL full-text search: `tsvector`, a generated column, a GIN index +- A search LiveView with debounced input + +## Lesson 7: User Profiles, Avatars and Private Messaging + +- Public profile pages at `/users/:username` +- Avatar uploads with `allow_upload/3` and `consume_uploaded_entries/3` +- Validating uploads: content type, size, and why you re-encode images +- Storing files: local disk in development, S3-compatible object storage in + production, behind one behaviour so the app does not care which +- Private messages: conversations, participants, read state +- Unread counts without a query per conversation + +## Lesson 8: Rich Text and Safe Rendering + +- Markdown as the single formatting system +- Rendering Markdown server-side and sanitizing the result +- **Why unsanitized user HTML is how forums get owned** โ€” XSS, and what + `raw/1` really does +- Image and file attachments in posts +- A live preview pane using a LiveView JS hook + +## Lesson 9: Roles, Permissions and Moderation + +- Generalizing `topic_owner?/2` into an authorization module +- Roles: member, moderator, administrator +- Permission checks in one place, used by both templates and handlers +- Moderator actions: lock, pin, move, edit, remove +- Reporting and a moderation queue + +## Lesson 10: Audit Trails and the Admin Dashboard + +- Recording who did what, when, and to which record +- Soft deletes: keeping the row, hiding the content +- Edit history on posts +- An admin dashboard LiveView +- Phoenix LiveDashboard behind an authorization check + +## Lesson 11: Notifications + +- Email notifications with Swoosh, and previewing them in development +- In-app notifications: a table, a bell, an unread count over PubSub +- Digests and per-user notification preferences +- Sending email reliably in production (a real adapter, retries, bounces) + +## Lesson 12: Accessible Design (a11y) + +- Auditing what we have built with a screen reader and a keyboard +- Semantics, landmarks, focus management in LiveView navigation +- Live regions for content that arrives over the socket +- Colour contrast in both daisyUI themes +- Automated checks in CI, and what they cannot catch + +## Lesson 13: Testing in Depth + +- What we have already been doing, named: `DataCase`, `ConnCase`, fixtures +- `Phoenix.LiveViewTest`: `render_click/3`, `render_submit/2`, `follow_redirect/3` +- Testing authorization by pushing events a hostile client would push +- Testing PubSub broadcasts and multi-session behaviour +- Property-based testing with StreamData +- Running the suite in CI + +## Lesson 14: Deploying elxrBB + +- `config/runtime.exs` and the environment variables that matter +- Releases: `mix release`, and the generated Dockerfile +- Running migrations on deploy +- Deploying to a platform (Fly.io, Render) or a plain VM +- TLS, `force_ssl`, secret management, and a security checklist +- Backups, and restoring one before you need to + +## Lesson 15: Customizing and Extending elxrBB + +- The extension points the design left open +- Adding a feature end to end, as a worked example +- Contributing back to the project ## Conclusion -- Reviewing what you've learned -- Exploring additional resources and further learning -- Final thoughts on building a complete web application with Elixir and Phoenix - -## Appendix A: Implementing an Event-Driven Architecture (Optional) -- Overview of event-driven architecture -- Refactoring the application to use events -- Implementing event handlers and subscribers - -## Appendix B: Object-Oriented vs. Functional Programming (Optional) -- Overview of object-oriented and functional programming paradigms -- Comparing the two approaches -- Understanding the advantages and disadvantages of each paradigm + +- What you have built +- Where to go next + +## Appendices (optional) + +### Appendix A: An Event-Driven Architecture + +- What events buy you, and what they cost +- Refactoring one slice of elxrBB to publish domain events + +### Appendix B: Object-Oriented vs. Functional Programming + +- The two paradigms side by side +- Why contexts are not services and structs are not objects + +### Appendix C: Subscriptions and Payment Processing + +- A "pro" tier gated on subscription state +- Integrating a payment provider, webhooks, and the states you must handle +- **Requires a third-party account and cannot be followed offline** + +### Appendix D: Browser Push and SMS Notifications + +- Web Push: service workers, VAPID keys, and permission UX +- SMS and voice via a telephony provider +- **Requires third-party accounts and cannot be followed offline** + +--- + +## Revisions to this plan + +The original sixteen-lesson outline was drafted before any code existed. Now +that lessons 1โ€“3 are implemented, several parts of it do not survive contact +with the application. + +**Lessons 4 and 5 were the same lesson.** "Threaded replies" and "sub-topics" +describe one feature: a reply with a `parent_id`. They are merged into +lesson 4. + +**Real-time moved from lesson 11 to lesson 5, and changed technology.** The +original plan reached for Phoenix Channels. elxrBB is a LiveView application, +and a LiveView broadcasts over `Phoenix.PubSub` and receives in +`handle_info/2` โ€” Channels are for clients that are *not* LiveViews. It also +belongs early: after lesson 4 the reply you post appears instantly for you and +not at all for anyone else, which is the wrong thing to leave standing for six +lessons. + +**Pagination and search were missing entirely.** Every list query in the +application today is unbounded. That is fine with five topics and an outage +with fifty thousand, and it is the single largest gap between "the tutorial +finishes" and "the application works". It is now lesson 6. + +**Testing moved from lesson 14 to lesson 13, and changed shape.** The +reference application has had tests since lesson 2, so a lesson that +introduces testing at the end would be describing a project that does not +exist. Lesson 13 now covers the parts we have *not* been using โ€” LiveView +interaction tests, PubSub assertions, property-based testing, CI โ€” and the +basics are taught where they are first used. + +**The animal-names lesson is gone.** Old lesson 12 proposed importing the +animal list into a database table and exposing it to JavaScript. Lesson 2 +already loads those lists at compile time, which is faster and simpler, and +the JavaScript half had no user-facing purpose. The genuinely useful JS-hook +material moves to lesson 8, where a Markdown preview pane gives it a reason to +exist. + +**Payments and SMS became appendices.** A tutorial step that cannot be +completed without a vendor account, a credit card, and a public webhook +endpoint is not a step most readers can take. The roles and permissions half +of old lesson 7 is real work and stays, as lesson 9; the subscription and +billing half moves to appendix C. Browser push and Twilio move to appendix D +for the same reason. + +**Profiles moved earlier and avatars later.** Usernames and biographies were +planned for lesson 6 but are part of registration, so they shipped in lesson 2. +What remains of old lesson 6 โ€” avatar uploads and private messaging โ€” is +lesson 7, with file storage behind a behaviour instead of naming one vendor's +block storage in the lesson title. + +**Rich text narrowed to one format.** Supporting Markdown *and* BBCode *and* a +WYSIWYG editor triples the work and teaches nothing three times. Lesson 8 does +Markdown properly, including the sanitization step that is the actual reason +this lesson matters. + +**Accessibility stayed at the back, with a caveat.** Lesson 12 is an audit and +a fix pass, which is a reasonable thing to do once there is something to +audit โ€” but a11y is not a feature you bolt on at the end, so the earlier +lessons carry inline notes where a choice has accessibility consequences. diff --git a/docs/00b-introduction.md b/docs/00b-introduction.md index 77a27e8..7384640 100644 --- a/docs/00b-introduction.md +++ b/docs/00b-introduction.md @@ -13,9 +13,9 @@ Our elxrBB forum application will include a variety of features that are commonl 1. User authentication and registration with email verification. 2. Publicly accessible discussion threads organized by topics. 3. Threaded replies and support for subtopics. -4. Real-time updates for all clients via a pub/sub architecture. +4. Real-time updates for all clients via Phoenix PubSub. 5. Upvote and downvote functionality for topics. -6. User profiles with avatars, biographies, and preferred names. +6. User profiles with avatars and biographies. 7. Private messaging between users. 8. User roles and a subscription system for "pro users." 9. Media upload capabilities and support for different text formatting options (Markdown, BBCode, and WYSIWYG). From 9db1a187eb3024a0dda8dc13d1f9168f504e95dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 05:31:44 +0000 Subject: [PATCH 4/6] Lesson 4: threaded replies, sub-topics and voting Written against the implementation in ephbaum/elxrBB. Merges what the outline had as lessons 4 and 5, since "threaded replies" and "sub-topics" are the same feature. Covers the self-referencing foreign key and why depth is stored rather than derived, the two check constraints that keep the denormalization honest, building a tree in two queries instead of one per level, and why sibling ordering needs an id tiebreak or the page reorders itself between renders. For voting: one table with two nullable targets and an exclusive-or check constraint, partial unique indexes and why they must be partial, summing scores rather than keeping a counter column, and the pinned-value clause that makes casting the same vote twice take it back. The web section covers recursive function components, the duplicate DOM id the test suite caught, the deliberate trade of a LiveView stream for a plain assign now that replies are a tree, and three habits for event payloads: parse rather than trust, check the id addresses something in scope, and prefer the non-raising getter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QKEb38aDmzRFVxFnM6Fe3X --- README.md | 5 +- STATUS.md | 17 +- docs/00a-outline.md | 8 +- docs/03-forum-functionality.md | 3 +- docs/04-threading-and-voting.md | 530 ++++++++++++++++++++++++++++++++ 5 files changed, 549 insertions(+), 14 deletions(-) create mode 100644 docs/04-threading-and-voting.md diff --git a/README.md b/README.md index 5000d4e..7ce2968 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ Build a forum web application with Elixir and Phoenix, one lesson at a time. | [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โ€“3 are written +[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 4 on is still an outline. +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. diff --git a/STATUS.md b/STATUS.md index 26b3676..e3643c9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -13,8 +13,8 @@ Numbering follows the revised [outline](docs/00a-outline.md). | 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 | ๐Ÿšง Next | ๐Ÿšง Next | -| 5 | Real-Time Updates with PubSub | ๐Ÿ“‹ Outlined | โŒ No | +| 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 | @@ -38,10 +38,13 @@ Legend: โœ… done ยท ๐Ÿšง in progress ยท โš ๏ธ partial ยท ๐Ÿ“‹ planned ยท โŒ no - 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 and appended live. Authors can edit and - delete their own posts. +- 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. -186 tests pass on `mix precommit`. +231 tests pass on `mix precommit`. ## Known gaps in the application @@ -49,8 +52,8 @@ These are true of the code today and are scheduled, not forgotten: - **No pagination.** `list_topics/1` and `list_replies/1` return everything. Lesson 6. -- **No real-time.** A posted reply appears for its author only; other readers - must reload. Lesson 5. +- **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. diff --git a/docs/00a-outline.md b/docs/00a-outline.md index 27713c7..b96079b 100644 --- a/docs/00a-outline.md +++ b/docs/00a-outline.md @@ -12,20 +12,20 @@ Status of each lesson is tracked in [STATUS.md](../STATUS.md). - Introducing Elixir and Phoenix - System requirements and setup -## Lesson 1: Setting Up the Environment โœ… +## [Lesson 1: Setting Up the Environment](01-setting-up.md) โœ… - Installing Elixir, Erlang/OTP and PostgreSQL - Installing the Phoenix project generator - Generating the elxrBB project and reading what it produced -## Lesson 2: User Accounts โœ… +## [Lesson 2: User Accounts](02-user-authentication.md) โœ… - Generating authentication with `mix phx.gen.auth` - Scopes, magic-link login and email confirmation - Assigning each new account a random username - User profiles: usernames and biographies -## Lesson 3: Forums, Topics and Replies โœ… +## [Lesson 3: Forums, Topics and Replies](03-forum-functionality.md) โœ… - The Forums context: forums, topics, replies - Schemas, associations and cascading deletes @@ -33,7 +33,7 @@ Status of each lesson is tracked in [STATUS.md](../STATUS.md). - LiveViews for browsing, posting and replying - Counting replies without an N+1 or a counter column -## Lesson 4: Threaded Replies, Sub-Topics and Voting +## [Lesson 4: Threaded Replies, Sub-Topics and Voting](04-threading-and-voting.md) โœ… - Self-referencing replies: `parent_id` and a nested tree - Rendering a thread recursively without an N+1 diff --git a/docs/03-forum-functionality.md b/docs/03-forum-functionality.md index 929915c..baf4df0 100644 --- a/docs/03-forum-functionality.md +++ b/docs/03-forum-functionality.md @@ -403,4 +403,5 @@ mix phx.server ## Next -Lesson 4 adds threaded replies and voting on top of this structure. +[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..d77e7ea --- /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/..._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 +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 +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 +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 +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 +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 +<.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 "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. From 7b1a1176129e41e98f02c115ed8d7442a9a48bb4 Mon Sep 17 00:00:00 2001 From: Eph Baum Date: Sat, 5 Sep 2026 00:11:59 +0000 Subject: [PATCH 5/6] Check lesson code blocks against the application The lessons quote the application's source and nothing verified the quotes. An audit of lessons 1-4 against the code found the quotes accurate but the comparison itself unreliable: checking a lesson against the application's HEAD reports drift wherever a later lesson changed the code on purpose. Lesson 3 shows replies as a flat LiveView stream and lesson 4 replaces that with a tree, so lesson 3 looks broken against HEAD and is not. So pin each lesson to the commit that concludes it, in lessons.exs, and check its blocks against that tree. Blocks name their source file in the fence. Comparison ignores whitespace and whole-line comments, so a lesson may re-indent an excerpt, reflow it to the page width, or leave out a comment it explains in prose; it may not change a name, a literal or the structure. A bare `...`, or a comment mentioning `...`, elides code the lesson is not showing. 27 of 39 source blocks are annotated and pass. The remaining 12 condense their source without marking the elision, which needs a judgement call per block; --strict fails on unannotated blocks so coverage can be ratcheted. Also correct lesson 1: it said Phoenix 1.8 needs Elixir 1.15 or newer, but this application's mix.exs requires 1.17. Add CI to run the check on push, on pull request, and weekly, since the application can drift without this repository changing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QKEb38aDmzRFVxFnM6Fe3X --- .github/workflows/lessons.yml | 37 +++++ README.md | 18 +++ bin/check_lessons.exs | 276 ++++++++++++++++++++++++++++++++ docs/01-setting-up.md | 5 +- docs/02-user-authentication.md | 6 +- docs/03-forum-functionality.md | 32 ++-- docs/04-threading-and-voting.md | 16 +- lessons.exs | 20 +++ 8 files changed, 381 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/lessons.yml create mode 100755 bin/check_lessons.exs create mode 100644 lessons.exs diff --git a/.github/workflows/lessons.yml b/.github/workflows/lessons.yml new file mode 100644 index 0000000..218f007 --- /dev/null +++ b/.github/workflows/lessons.yml @@ -0,0 +1,37 @@ +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: ["**"] + 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@v4 + + - name: Check out the application the lessons teach + uses: actions/checkout@v4 + 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 7ce2968..7d76c53 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,24 @@ 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 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)|