From e780d6b6e597374ad2ef951b441193f1a97f2803 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 10:55:44 +0000 Subject: [PATCH 01/46] Add Dockerfile-based Dokku deployment for monorepo layout Uses a multi-stage Docker build that copies both app/ and blogex/, preserving the path dependency. Includes release scripts, migration module, and a sample Dokku setup script. --- .dockerignore | 31 +++++++++++++ .gitignore | 2 + Dockerfile | 86 ++++++++++++++++++++++++++++++++++++ app.json | 8 ++++ app/lib/firehose/release.ex | 31 +++++++++++++ app/rel/overlays/bin/migrate | 6 +++ app/rel/overlays/bin/server | 6 +++ dokku-setup.sh.sample | 49 ++++++++++++++++++++ 8 files changed, 219 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.json create mode 100644 app/lib/firehose/release.ex create mode 100755 app/rel/overlays/bin/migrate create mode 100755 app/rel/overlays/bin/server create mode 100644 dokku-setup.sh.sample diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..41a8c0e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Git +.git + +# Build artifacts +app/_build +app/deps +blogex/_build +blogex/deps + +# Dev/test only +app/test +blogex/test +app/.formatter.exs +blogex/.formatter.exs + +# IDE +.devcontainer +.claude + +# Documentation +*.md +!app/README.md + +# Misc +app/tmp +app/cover +app/doc +blogex/doc + +# Dokku setup (may contain secrets) +dokku-setup.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b842451 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Dokku setup (may contain secrets) +dokku-setup.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e0f7035 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# Dockerfile for Dokku deployment +# Multi-stage build for Phoenix/Elixir app with monorepo layout + +ARG ELIXIR_VERSION=1.18.3 +ARG OTP_VERSION=27.2.4 +ARG DEBIAN_VERSION=bookworm-20260316-slim + +ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}" + +# ============================================================================= +# Build stage +# ============================================================================= +FROM ${BUILDER_IMAGE} AS builder + +RUN apt-get update -y && apt-get install -y build-essential git \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +WORKDIR /build + +# Install hex + rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +ENV MIX_ENV="prod" + +# Copy blogex dependency first (changes less often) +COPY blogex /build/blogex + +# Copy app dependency files first for better layer caching +COPY app/mix.exs app/mix.lock /build/app/ +WORKDIR /build/app + +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# Copy compile-time config files +COPY app/config/config.exs app/config/${MIX_ENV}.exs config/ +RUN mix deps.compile + +# Copy application source and compile +COPY app/priv priv +COPY app/assets assets +COPY app/lib lib +COPY app/rel rel +COPY app/config/runtime.exs config/ + +RUN mix compile + +# Build assets after compile (phoenix-colocated hooks need compiled app) +RUN mix assets.deploy + +# Build the release +RUN mix release + +# ============================================================================= +# Runtime stage +# ============================================================================= +FROM ${RUNNER_IMAGE} + +RUN apt-get update -y && \ + apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +# Set the locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +WORKDIR /app + +RUN chown nobody /app +ENV MIX_ENV="prod" + +# Copy the release from the build stage +COPY --from=builder --chown=nobody:root /build/app/_build/${MIX_ENV}/rel/firehose ./ + +USER nobody + +# Dokku uses the EXPOSE port for routing +EXPOSE 4000 + +ENV PHX_SERVER=true + +CMD ["/app/bin/server"] diff --git a/app.json b/app.json new file mode 100644 index 0000000..45344ec --- /dev/null +++ b/app.json @@ -0,0 +1,8 @@ +{ + "name": "firehose", + "scripts": { + "dokku": { + "postdeploy": "/app/bin/migrate" + } + } +} diff --git a/app/lib/firehose/release.ex b/app/lib/firehose/release.ex new file mode 100644 index 0000000..a9d3e6a --- /dev/null +++ b/app/lib/firehose/release.ex @@ -0,0 +1,31 @@ +defmodule Firehose.Release do + @moduledoc """ + Tasks for production releases (e.g., database migrations). + + Usage from Dokku: + dokku run APP_NAME /app/bin/migrate + """ + + @app :firehose + + def migrate do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + Application.load(@app) + end +end diff --git a/app/rel/overlays/bin/migrate b/app/rel/overlays/bin/migrate new file mode 100755 index 0000000..c046585 --- /dev/null +++ b/app/rel/overlays/bin/migrate @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")"/.. + +exec ./bin/firehose eval Firehose.Release.migrate diff --git a/app/rel/overlays/bin/server b/app/rel/overlays/bin/server new file mode 100755 index 0000000..f412278 --- /dev/null +++ b/app/rel/overlays/bin/server @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")"/.. + +PHX_SERVER=true exec ./bin/firehose start diff --git a/dokku-setup.sh.sample b/dokku-setup.sh.sample new file mode 100644 index 0000000..87d85b8 --- /dev/null +++ b/dokku-setup.sh.sample @@ -0,0 +1,49 @@ +#!/bin/bash +# dokku-setup.sh.sample - Set up Dokku app for firehose +# +# USAGE: +# 1. Copy to dokku-setup.sh: cp dokku-setup.sh.sample dokku-setup.sh +# 2. Fill in any empty strings below +# 3. Run on Dokku server: ./dokku-setup.sh +# +# Do NOT commit dokku-setup.sh (contains secrets) + +set -e + +APP="firehose" # <-- change to your desired Dokku app name / hostname +PHX_HOST="$APP" # <-- change to your full domain, e.g., firehose.example.com + +# Auto-generate secrets +SECRET_KEY_BASE=$(openssl rand -base64 64 | tr -d '\n') + +echo "==> Creating Dokku app: $APP" +dokku apps:create "$APP" || echo "App may already exist" + +echo "==> Creating PostgreSQL database" +dokku postgres:create "${APP}-db" || echo "Database may already exist" +dokku postgres:link "${APP}-db" "$APP" || echo "Database may already be linked" + +echo "==> Setting environment variables" +dokku config:set --no-restart "$APP" \ + SECRET_KEY_BASE="$SECRET_KEY_BASE" \ + PHX_HOST="$PHX_HOST" \ + PORT="4000" + +# Optional: set custom domain (uncomment and edit) +# dokku domains:set "$APP" "$PHX_HOST" + +# Optional: set up SSL with Let's Encrypt (uncomment) +# dokku letsencrypt:enable "$APP" + +echo "" +echo "==> Setup complete!" +echo "" +echo "Next steps:" +echo " 1. From your local machine, add the git remote:" +echo " git remote add dokku dokku@YOUR_SERVER:$APP" +echo "" +echo " 2. Push to deploy:" +echo " git push dokku main" +echo "" +echo " 3. Run migrations (first deploy):" +echo " dokku run $APP /app/bin/migrate" From f563d3c26af7731e782e18745872b63b95baeb41 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 11:22:54 +0000 Subject: [PATCH 02/46] Add postgres to devcontainer / compose --- .devcontainer/devcontainer.json | 11 +++++++---- .devcontainer/docker-compose.yml | 28 ++++++++++++++++++++++++++++ app/config/dev.exs | 2 +- app/config/test.exs | 2 +- test.sh | 8 ++++++++ 5 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 .devcontainer/docker-compose.yml create mode 100755 test.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 731e74b..0a76dd0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,10 +1,13 @@ { "$schema": "https://containers.dev/implementors/json_schema/", - "build": { - "dockerfile": "Dockerfile" + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/firehose", + "remoteUser": "root", + "containerEnv": { + "DB_HOST": "db", + "HOME": "/home/vscode" }, - "remoteUser": "vscode", - "runArgs": [], "features": { "ghcr.io/devcontainers/features/python:1": {}, "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..5b3e22d --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,28 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - ..:/workspaces/firehose:cached + command: sleep infinity + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/app/config/dev.exs b/app/config/dev.exs index 1c0b8a2..2aa21f5 100644 --- a/app/config/dev.exs +++ b/app/config/dev.exs @@ -4,7 +4,7 @@ import Config config :firehose, Firehose.Repo, username: "postgres", password: "postgres", - hostname: "localhost", + hostname: System.get_env("DB_HOST") || "localhost", database: "firehose_dev", stacktrace: true, show_sensitive_data_on_connection_error: true, diff --git a/app/config/test.exs b/app/config/test.exs index 83a8cc7..ee30aa3 100644 --- a/app/config/test.exs +++ b/app/config/test.exs @@ -8,7 +8,7 @@ import Config config :firehose, Firehose.Repo, username: "postgres", password: "postgres", - hostname: "localhost", + hostname: System.get_env("DB_HOST") || "localhost", database: "firehose_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, pool_size: System.schedulers_online() * 2 diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..f315783 --- /dev/null +++ b/test.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +export ELIXIR_ERL_OPTIONS="+fnu" +/home/vscode/.local/bin/mise trust /workspaces/firehose/mise.toml 2>/dev/null +eval "$(/home/vscode/.local/bin/mise activate bash)" +cd /workspaces/firehose/app +mix deps.get +mix test From 9bad5d3770afaec804891d988286ed72ed19ca7f Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 11:30:27 +0000 Subject: [PATCH 03/46] Enable UTF-8 in devcontainer --- .devcontainer/Dockerfile | 5 +++++ test.sh | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 605d950..6b374cd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,11 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 USER root +RUN apt-get update && apt-get install -y locales && \ + sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ + locale-gen en_US.UTF-8 +ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 + RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ apt-get install -y nodejs diff --git a/test.sh b/test.sh index f315783..16ac260 100755 --- a/test.sh +++ b/test.sh @@ -1,6 +1,5 @@ #!/bin/bash set -e -export ELIXIR_ERL_OPTIONS="+fnu" /home/vscode/.local/bin/mise trust /workspaces/firehose/mise.toml 2>/dev/null eval "$(/home/vscode/.local/bin/mise activate bash)" cd /workspaces/firehose/app From e0e5acb3226ecee2277348c4704f9d2fa4002127 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 12:07:20 +0000 Subject: [PATCH 04/46] open port 4050 for testing in docker compose file --- .devcontainer/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 5b3e22d..311964f 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,6 +5,8 @@ services: dockerfile: Dockerfile volumes: - ..:/workspaces/firehose:cached + ports: + - "4050:4050" command: sleep infinity depends_on: db: From 24847ca7fd1c32b1faa9d5a5e724c963dcb8e4a7 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 12:11:28 +0000 Subject: [PATCH 05/46] Clearly mark sample posts as generated --- .../blog/engineering/2026/02-15-testing-liveview-at-scale.md | 2 ++ .../blog/engineering/2026/03-10-rebuilding-data-pipeline.md | 2 ++ blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md | 2 ++ blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/blogex/priv/blog/engineering/2026/02-15-testing-liveview-at-scale.md b/blogex/priv/blog/engineering/2026/02-15-testing-liveview-at-scale.md index bd3effa..4dd7ffe 100644 --- a/blogex/priv/blog/engineering/2026/02-15-testing-liveview-at-scale.md +++ b/blogex/priv/blog/engineering/2026/02-15-testing-liveview-at-scale.md @@ -5,6 +5,8 @@ description: "Our testing strategy for 200+ LiveView modules" } --- +*This is a sample blog post, generated to show what blogex can do.* + With over 200 LiveView modules in our codebase, we needed a testing strategy that was both fast and reliable. Here's what we landed on. diff --git a/blogex/priv/blog/engineering/2026/03-10-rebuilding-data-pipeline.md b/blogex/priv/blog/engineering/2026/03-10-rebuilding-data-pipeline.md index 2c05f2e..2281012 100644 --- a/blogex/priv/blog/engineering/2026/03-10-rebuilding-data-pipeline.md +++ b/blogex/priv/blog/engineering/2026/03-10-rebuilding-data-pipeline.md @@ -5,6 +5,8 @@ description: "How we replaced our Kafka consumer with Broadway for 10x throughput" } --- +*This is a sample blog post, generated to show what blogex can do.* + Last quarter we hit a wall with our homegrown Kafka consumer. Message lag was growing, backpressure was non-existent, and our on-call engineers were losing sleep. We decided to rebuild on [Broadway](https://github.com/dashbitco/broadway). diff --git a/blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md b/blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md index ac4c483..c7ad9bd 100644 --- a/blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md +++ b/blogex/priv/blog/release-notes/2026/02-01-v2-3-0.md @@ -5,6 +5,8 @@ description: "Reliable webhook delivery, dark mode, and improved search" } --- +*This is a sample blog post, generated to show what blogex can do.* + Here's what landed in v2.3.0. ## Webhook Reliability diff --git a/blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md b/blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md index aaad60b..03316ee 100644 --- a/blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md +++ b/blogex/priv/blog/release-notes/2026/03-01-v2-4-0.md @@ -5,6 +5,8 @@ description: "New team dashboards, API rate limiting, and 12 bug fixes" } --- +*This is a sample blog post, generated to show what blogex can do.* + We're excited to ship v2.4.0 with two major features and a pile of bug fixes. ## Team Dashboards From 6f2beb8bb8148e3c775eb01c1d37cd8917d85cb6 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 13:22:12 +0000 Subject: [PATCH 06/46] Add MIT license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..33ebdb7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Living Software LTD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 9e6252e1e76ea9c7835c9fc27379bb2c97ae0193 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 13:48:12 +0000 Subject: [PATCH 07/46] set DATABASE_URL --- dokku-setup.sh.sample | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/dokku-setup.sh.sample b/dokku-setup.sh.sample index 87d85b8..13019eb 100644 --- a/dokku-setup.sh.sample +++ b/dokku-setup.sh.sample @@ -11,6 +11,7 @@ set -e APP="firehose" # <-- change to your desired Dokku app name / hostname +DB_NAME="firehose_db" # <-- valid chars: [A-Za-z0-9_] only PHX_HOST="$APP" # <-- change to your full domain, e.g., firehose.example.com # Auto-generate secrets @@ -20,8 +21,8 @@ echo "==> Creating Dokku app: $APP" dokku apps:create "$APP" || echo "App may already exist" echo "==> Creating PostgreSQL database" -dokku postgres:create "${APP}-db" || echo "Database may already exist" -dokku postgres:link "${APP}-db" "$APP" || echo "Database may already be linked" +dokku postgres:create "${DB_NAME}" || echo "Database may already exist" +dokku postgres:link "${DB_NAME}" "$APP" || echo "Database may already be linked" echo "==> Setting environment variables" dokku config:set --no-restart "$APP" \ @@ -29,21 +30,18 @@ dokku config:set --no-restart "$APP" \ PHX_HOST="$PHX_HOST" \ PORT="4000" -# Optional: set custom domain (uncomment and edit) -# dokku domains:set "$APP" "$PHX_HOST" - -# Optional: set up SSL with Let's Encrypt (uncomment) -# dokku letsencrypt:enable "$APP" - echo "" echo "==> Setup complete!" echo "" +echo "DATABASE_URL was set automatically by postgres:link." +echo "" echo "Next steps:" echo " 1. From your local machine, add the git remote:" echo " git remote add dokku dokku@YOUR_SERVER:$APP" echo "" echo " 2. Push to deploy:" echo " git push dokku main" +echo " (migrations run automatically via app.json postdeploy)" echo "" -echo " 3. Run migrations (first deploy):" -echo " dokku run $APP /app/bin/migrate" +echo " 3. Set up SSL (after first deploy):" +echo " dokku letsencrypt:enable $APP" From a5dee5c21ee6469424d02c5f52f7d49ac4798e35 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 13:55:49 +0000 Subject: [PATCH 08/46] set default port to 5000 for production --- Dockerfile | 8 ++++---- app/config/runtime.exs | 2 +- dokku-setup.sh.sample | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index e0f7035..e9150da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,9 +64,9 @@ RUN apt-get update -y && \ # Set the locale RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 WORKDIR /app @@ -79,7 +79,7 @@ COPY --from=builder --chown=nobody:root /build/app/_build/${MIX_ENV}/rel/firehos USER nobody # Dokku uses the EXPOSE port for routing -EXPOSE 4000 +EXPOSE 5000 ENV PHX_SERVER=true diff --git a/app/config/runtime.exs b/app/config/runtime.exs index d48bf27..f0f4e40 100644 --- a/app/config/runtime.exs +++ b/app/config/runtime.exs @@ -51,7 +51,7 @@ if config_env() == :prod do """ host = System.get_env("PHX_HOST") || "example.com" - port = String.to_integer(System.get_env("PORT") || "4000") + port = String.to_integer(System.get_env("PORT") || "5000") config :firehose, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") diff --git a/dokku-setup.sh.sample b/dokku-setup.sh.sample index 13019eb..ed49e76 100644 --- a/dokku-setup.sh.sample +++ b/dokku-setup.sh.sample @@ -28,7 +28,7 @@ echo "==> Setting environment variables" dokku config:set --no-restart "$APP" \ SECRET_KEY_BASE="$SECRET_KEY_BASE" \ PHX_HOST="$PHX_HOST" \ - PORT="4000" + PORT="5000" echo "" echo "==> Setup complete!" From 9be7c1774bab6c2dcea58318dadbf1d4d3a28d39 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 14:38:45 +0000 Subject: [PATCH 09/46] Dokku setup script did not work that well, fixed by hand --- blogex/mix.lock | 22 ++++++++++++++++++++ dokku-setup.sh.sample | 47 ------------------------------------------- 2 files changed, 22 insertions(+), 47 deletions(-) create mode 100644 blogex/mix.lock delete mode 100644 dokku-setup.sh.sample diff --git a/blogex/mix.lock b/blogex/mix.lock new file mode 100644 index 0000000..32bf388 --- /dev/null +++ b/blogex/mix.lock @@ -0,0 +1,22 @@ +%{ + "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_publisher": {:hex, :nimble_publisher, "1.1.1", "3ea4d4cfca45b11a5377bce7608367a9ddd7e717a9098161d8439eca23e239aa", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d67e15bddf07e8c60f75849008b78ea8c6b2b4ae8e3f882ccf0a22d57bd42ed0"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/dokku-setup.sh.sample b/dokku-setup.sh.sample deleted file mode 100644 index ed49e76..0000000 --- a/dokku-setup.sh.sample +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# dokku-setup.sh.sample - Set up Dokku app for firehose -# -# USAGE: -# 1. Copy to dokku-setup.sh: cp dokku-setup.sh.sample dokku-setup.sh -# 2. Fill in any empty strings below -# 3. Run on Dokku server: ./dokku-setup.sh -# -# Do NOT commit dokku-setup.sh (contains secrets) - -set -e - -APP="firehose" # <-- change to your desired Dokku app name / hostname -DB_NAME="firehose_db" # <-- valid chars: [A-Za-z0-9_] only -PHX_HOST="$APP" # <-- change to your full domain, e.g., firehose.example.com - -# Auto-generate secrets -SECRET_KEY_BASE=$(openssl rand -base64 64 | tr -d '\n') - -echo "==> Creating Dokku app: $APP" -dokku apps:create "$APP" || echo "App may already exist" - -echo "==> Creating PostgreSQL database" -dokku postgres:create "${DB_NAME}" || echo "Database may already exist" -dokku postgres:link "${DB_NAME}" "$APP" || echo "Database may already be linked" - -echo "==> Setting environment variables" -dokku config:set --no-restart "$APP" \ - SECRET_KEY_BASE="$SECRET_KEY_BASE" \ - PHX_HOST="$PHX_HOST" \ - PORT="5000" - -echo "" -echo "==> Setup complete!" -echo "" -echo "DATABASE_URL was set automatically by postgres:link." -echo "" -echo "Next steps:" -echo " 1. From your local machine, add the git remote:" -echo " git remote add dokku dokku@YOUR_SERVER:$APP" -echo "" -echo " 2. Push to deploy:" -echo " git push dokku main" -echo " (migrations run automatically via app.json postdeploy)" -echo "" -echo " 3. Set up SSL (after first deploy):" -echo " dokku letsencrypt:enable $APP" From 3837a720597a445ce1614f0386d2b9b13dfd55f5 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 18 Mar 2026 15:03:24 +0000 Subject: [PATCH 10/46] update blog post, and run credo with 'pi' --- Makefile | 27 ++++++++ app/Makefile | 32 ++++++++++ app/lib/firehose/blogs/engineering_blog.ex | 3 + app/lib/firehose/blogs/release_notes.ex | 3 + app/lib/firehose_web.ex | 2 +- .../components/core_components.ex | 3 +- .../components/layouts/app.html.heex | 5 +- .../controllers/blog_controller.ex | 1 + .../controllers/page_html/home.html.heex | 11 +++- app/mix.exs | 3 +- app/mix.lock | 2 + .../engineering/2026/03-17-why-firehose.md | 22 ++++--- .../firehose_web/controllers/blog_test.exs | 4 +- app/test/support/data_case.ex | 5 +- planner_request.md | 61 +++++++++++++++++++ 15 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 Makefile create mode 100644 app/Makefile create mode 100644 planner_request.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d8433d --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# Makefile for Firehose monorepo + +.PHONY: check precommit deps compile test format + +# Common check target that runs all static analysis +check: + @echo "Running static analysis..." + @make -C app MISE_BIN=/home/vscode/.local/bin/mise check + +# Precommit target for CI/pre-commit hooks +precommit: check + +# Sync dependencies +deps: + @make -C app deps + +# Compile the project +compile: + @make -C app compile + +# Run tests +test: + @make -C app test + +# Format code +format: + @make -C app format \ No newline at end of file diff --git a/app/Makefile b/app/Makefile new file mode 100644 index 0000000..bf93fee --- /dev/null +++ b/app/Makefile @@ -0,0 +1,32 @@ +# Makefile for Firehose app + +MISE_BIN ?= /home/vscode/.local/bin/mise +MISE_EXEC = $(MISE_BIN) exec -- + +.PHONY: check precommit deps compile test format credo + +# Run all static analysis checks +check: credo format + +# Precommit target for CI/pre-commit hooks +precommit: check compile + +# Sync dependencies +deps: + $(MISE_EXEC) mix deps.get + +# Compile the project +compile: + $(MISE_EXEC) mix compile --warnings-as-errors + +# Run tests +test: deps + $(MISE_EXEC) mix test + +# Format code +format: + $(MISE_EXEC) mix format + +# Run Credo static analysis +credo: + $(MISE_EXEC) mix credo --strict \ No newline at end of file diff --git a/app/lib/firehose/blogs/engineering_blog.ex b/app/lib/firehose/blogs/engineering_blog.ex index cf13544..344adee 100644 --- a/app/lib/firehose/blogs/engineering_blog.ex +++ b/app/lib/firehose/blogs/engineering_blog.ex @@ -1,4 +1,7 @@ defmodule Firehose.EngineeringBlog do + @moduledoc """ + Engineering blog configuration. + """ use Blogex.Blog, blog_id: :engineering, app: :firehose, diff --git a/app/lib/firehose/blogs/release_notes.ex b/app/lib/firehose/blogs/release_notes.ex index 4153c97..f9d3f37 100644 --- a/app/lib/firehose/blogs/release_notes.ex +++ b/app/lib/firehose/blogs/release_notes.ex @@ -1,4 +1,7 @@ defmodule Firehose.ReleaseNotes do + @moduledoc """ + Release notes blog configuration. + """ use Blogex.Blog, blog_id: :release_notes, app: :firehose, diff --git a/app/lib/firehose_web.ex b/app/lib/firehose_web.ex index 2579d3a..0aa22ea 100644 --- a/app/lib/firehose_web.ex +++ b/app/lib/firehose_web.ex @@ -88,8 +88,8 @@ defmodule FirehoseWeb do import FirehoseWeb.CoreComponents # Common modules used in templates - alias Phoenix.LiveView.JS alias FirehoseWeb.Layouts + alias Phoenix.LiveView.JS # Routes generation with the ~p sigil unquote(verified_routes()) diff --git a/app/lib/firehose_web/components/core_components.ex b/app/lib/firehose_web/components/core_components.ex index 603479e..dcb60ad 100644 --- a/app/lib/firehose_web/components/core_components.ex +++ b/app/lib/firehose_web/components/core_components.ex @@ -29,6 +29,7 @@ defmodule FirehoseWeb.CoreComponents do use Phoenix.Component use Gettext, backend: FirehoseWeb.Gettext + alias Phoenix.HTML.Form alias Phoenix.LiveView.JS @doc """ @@ -181,7 +182,7 @@ defmodule FirehoseWeb.CoreComponents do def input(%{type: "checkbox"} = assigns) do assigns = assign_new(assigns, :checked, fn -> - Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + Form.normalize_value("checkbox", assigns[:value]) end) ~H""" diff --git a/app/lib/firehose_web/components/layouts/app.html.heex b/app/lib/firehose_web/components/layouts/app.html.heex index 2d81710..4efb292 100644 --- a/app/lib/firehose_web/components/layouts/app.html.heex +++ b/app/lib/firehose_web/components/layouts/app.html.heex @@ -1,6 +1,9 @@ - <.post_index posts={@posts} base_path={@base_path} /> + <.post_index posts={@posts} base_path={@base_path} current_tag={@tag} /> ← All posts diff --git a/app/priv/blog/engineering/2026/03-16-hello-world.md b/app/priv/blog/engineering/2026/03-16-hello-world.md index 123210f..d96a4c2 100644 --- a/app/priv/blog/engineering/2026/03-16-hello-world.md +++ b/app/priv/blog/engineering/2026/03-16-hello-world.md @@ -1,6 +1,7 @@ %{ title: "Hello World", author: "Firehose Team", + published: false, tags: ~w(elixir phoenix), description: "Our first engineering blog post" } diff --git a/app/priv/blog/engineering/2026/03-20-llm-simple-play.md b/app/priv/blog/engineering/2026/03-20-llm-simple-play.md new file mode 100644 index 0000000..cfd32f1 --- /dev/null +++ b/app/priv/blog/engineering/2026/03-20-llm-simple-play.md @@ -0,0 +1,13 @@ + +%{ + title: "Coding agent from scratch - a loop with tools, not that complicated", + author: "Willem van den Ende", + published: True, + tags: ~w(llm coding-agent python exercise), + description: "Coding agents are not that complicated. A loop with some tools. I found an interactive tutorial that lets you experience it" +} +--- + +I had started on a "Write your own coding agent" exercise. Four iterations in, actually. And then I found [Tiny Agents]( https://tinyagents.dev/lesson/agent-loop), a set of interactive exercises that let you experience how agents work, from a simple chat request, through a tool, more tools etc. It has a live graph, that visualises of the flow of data and actions. + +It is good fun to play with, it starts simple and builds up. It lets you inspect the messages between the 'agent' loop code and the large language model server (which is just HTTP and some JSON). diff --git a/app/test/firehose_web/controllers/blog_tags_test.exs b/app/test/firehose_web/controllers/blog_tags_test.exs new file mode 100644 index 0000000..51b8b15 --- /dev/null +++ b/app/test/firehose_web/controllers/blog_tags_test.exs @@ -0,0 +1,123 @@ +defmodule FirehoseWeb.BlogTagsTest do + use FirehoseWeb.ConnCase + + defp goto_engineering_tag_page(conn, tag) do + path = "/blog/engineering/tag/#{tag}" + conn = get(conn, path) + body = html_response(conn, 200) + assert body =~ ~s(tagged "#{tag}") + assert body =~ "Engineering Blog" + body + end + + defp goto_releases_tag_page(conn, tag) do + path = "/blog/releases/tag/#{tag}" + conn = get(conn, path) + body = html_response(conn, 200) + assert body =~ ~s(tagged "#{tag}") + assert body =~ "Release Notes" + body + end + + describe "engineering blog tags" do + test "GET /blog/engineering/tag/:tag shows tag page with all posts", %{conn: conn} do + body = goto_engineering_tag_page(conn, "elixir") + assert body =~ "Hello World" + end + + test "GET /blog/engineering/tag/:tag page shows filtered posts", %{conn: conn} do + body = goto_engineering_tag_page(conn, "phoenix") + assert body =~ "Hello World" + end + + test "GET /blog/engineering/tag/:tag page shows empty list for nonexistent tag", %{ + conn: conn + } do + body = get(conn, "/blog/engineering/tag/nonexistent-tag") + assert html_response(body, 200) =~ ~s(tagged "nonexistent-tag") + end + end + + describe "release notes blog tags" do + test "GET /blog/releases/tag/:tag shows tag page with all posts", %{conn: conn} do + body = goto_releases_tag_page(conn, "release") + assert body =~ "v0.1.0 Released" + end + + test "GET /blog/releases/tag/:tag page shows filtered posts", %{conn: conn} do + body = get(conn, "/blog/releases/tag/nonexistent-tag") + assert html_response(body, 200) =~ ~s(tagged "nonexistent-tag") + end + end + + describe "tag URL pattern" do + test "tag URLs follow pattern /blog/:blog_id/tag/:tag for engineering blog", %{conn: conn} do + # Test that the tag route exists and works correctly + conn = get(conn, "/blog/engineering/tag/elixir") + assert html_response(conn, 200) =~ ~s(tagged "elixir") + + conn = get(conn, "/blog/engineering/tag/phoenix") + assert html_response(conn, 200) =~ ~s(tagged "phoenix") + end + + test "tag URLs follow pattern /blog/:blog_id/tag/:tag for releases blog", %{conn: conn} do + # Test that the tag route exists and works correctly + conn = get(conn, "/blog/releases/tag/release") + assert html_response(conn, 200) =~ ~s(tagged "release") + end + + test "nonexistent tags return 200 with empty post list", %{conn: conn} do + conn = get(conn, "/blog/engineering/tag/nonexistent-tag") + assert html_response(conn, 200) + end + end + + describe "tag page structure" do + test "tag page has proper layout and back link", %{conn: conn} do + body = goto_engineering_tag_page(conn, "elixir") + + assert body =~ "Engineering Blog" + assert body =~ ~s(tagged "elixir") + assert body =~ "All posts" + end + + test "release tag page has proper layout and back link", %{conn: conn} do + body = goto_releases_tag_page(conn, "release") + + assert body =~ "Release Notes" + assert body =~ ~s(tagged "release") + assert body =~ "All posts" + end + end + + describe "clickable tags on index page" do + test "tags are rendered as clickable links on engineering blog index", %{ + conn: conn + } do + conn = get(conn, "/blog/engineering") + body = html_response(conn, 200) + + # Verify tag links exist with correct href pattern + assert body =~ ~r{href="/blog/engineering/tag/meta"} + assert body =~ ~r{href="/blog/engineering/tag/ai"} + end + + test "tags are rendered as clickable links on releases blog index", %{ + conn: conn + } do + conn = get(conn, "/blog/releases") + body = html_response(conn, 200) + + # Verify tag link exists + assert body =~ ~r{href="/blog/releases/tag/release"} + end + + test "tag links have proper styling classes", %{conn: conn} do + conn = get(conn, "/blog/engineering") + body = html_response(conn, 200) + + # Verify blogex-tag-link class is present for tag links + assert body =~ ~r{class="[^"]*blogex-tag-link} + end + end +end \ No newline at end of file diff --git a/blogex/lib/blogex/components.ex b/blogex/lib/blogex/components.ex index 393624f..2de29ce 100644 --- a/blogex/lib/blogex/components.ex +++ b/blogex/lib/blogex/components.ex @@ -35,7 +35,7 @@ defmodule Blogex.Components do

{post.title}

- <.post_meta post={post} /> + <.post_meta post={post} base_path={@base_path} />

{post.description}

@@ -49,15 +49,17 @@ defmodule Blogex.Components do ## Attributes * `:post` - a `%Blogex.Post{}` struct (required) + * `:base_path` - base URL path for tag links (required) """ attr :post, :map, required: true + attr :base_path, :string, required: true def post_show(assigns) do ~H"""

{@post.title}

- <.post_meta post={@post} /> + <.post_meta post={@post} base_path={@base_path} />
{Phoenix.HTML.raw(@post.body)} @@ -68,8 +70,16 @@ defmodule Blogex.Components do @doc """ Renders post metadata (date, author, tags). + + ## Attributes + + * `:post` - a `%Blogex.Post{}` struct (required) + * `:base_path` - base URL path for tag links (required) + * `:current_tag` - currently selected tag for highlighting (optional) """ attr :post, :map, required: true + attr :base_path, :string, required: true + attr :current_tag, :string, default: nil def post_meta(assigns) do ~H""" @@ -80,9 +90,13 @@ defmodule Blogex.Components do - + {tag} - +
""" end diff --git a/blogex/lib/blogex/layout.ex b/blogex/lib/blogex/layout.ex index dba26c6..921fba4 100644 --- a/blogex/lib/blogex/layout.ex +++ b/blogex/lib/blogex/layout.ex @@ -64,7 +64,7 @@ defmodule Blogex.Layout do - <.post_show post={@post} /> + <.post_show post={@post} base_path={@base_path} /> """ diff --git a/context.md b/context.md index 3282c66..92ee97f 100644 --- a/context.md +++ b/context.md @@ -1,172 +1,7 @@ -# Code Context +Investigation complete. Found the tag implementation details: -## Files Retrieved -List with exact line ranges: -1. `app/test/firehose_web/controllers/blog_test.exs` (lines 1-128) - Comprehensive blog controller tests covering HTML and JSON API endpoints for engineering blog and release notes -2. `app/lib/firehose_web/controllers/blog_controller.ex` (lines 1-79) - Blog controller with pagination, 404 handling, and input validation -3. `app/test/support/conn_case.ex` (lines 1-38) - Test case template for connection tests -4. `app/lib/firehose/blogs/engineering_blog.ex` (lines 1-7) - Engineering blog module configuration -5. `app/lib/firehose/blogs/release_notes.ex` (lines 1-7) - Release notes blog module configuration +**Key Finding**: The `post_meta` component in `/workspaces/firehose/blogex/lib/blogex/components.ex` (lines 83-85) renders tags as plain text without links, while there's already a working `tag_list` component (lines 93-115) that properly creates links with the pattern `href={"#{@base_path}/tag/#{tag}"}`. -## Key Code +**Route structure**: `/tag/:tag` in `/workspaces/firehose/blogex/lib/blogex/router.ex` (line 62) handles tag filtering via `blog.posts_by_tag(tag)`. -### Test Organization -```elixir -# Current structure has 4 describe blocks: -describe "engineering blog (HTML)" # 3 tests -describe "input validation" # 5 tests (newly added in last commit) -describe "release notes blog (HTML)" # 2 tests -describe "engineering blog (JSON API)" # 4 tests -describe "release notes blog (JSON API)" # 3 tests -``` - -### Input Validation Logic (blog_controller.ex, lines 68-76) -```elixir -defp parse_page(nil), do: 1 -defp parse_page(str) do - case Integer.parse(str) do - {page, ""} when page > 0 -> page - _ -> 1 - end -end -``` - -### Test Coverage Added in Last Commit -```elixir -describe "input validation" do - test "GET /blog/nonexistent returns 404", %{conn: conn} do - conn = get(conn, "/blog/nonexistent") - assert html_response(conn, 404) - end - - test "GET /blog/engineering?page=abc falls back to page 1", %{conn: conn} do - conn = get(conn, "/blog/engineering?page=abc") - assert html_response(conn, 200) =~ "Engineering Blog" - end - - test "GET /blog/engineering?page=-1 falls back to page 1", %{conn: conn} do - conn = get(conn, "/blog/engineering?page=-1") - assert html_response(conn, 200) =~ "Engineering Blog" - end - - test "GET /blog/engineering?page=0 falls back to page 1", %{conn: conn} do - conn = get(conn, "/blog/engineering?page=0") - assert html_response(conn, 200) =~ "Engineering Blog" - end - - test "GET /blog/engineering/nonexistent-post returns 404", %{conn: conn} do - assert_raise Blogex.NotFoundError, fn -> - get(conn, "/blog/engineering/nonexistent-post") - end - end -end -``` - -## Architecture -The application uses: -- **Blogex** library for blog functionality (engineering blog and release notes) -- **Phoenix** framework for web endpoints -- **ConnCase** test helper for connection testing -- Two blog types: `Firehose.EngineeringBlog` and `Firehose.ReleaseNotes` -- Pagination through `blog.paginate(page)` method -- 404 handling via `Blogex.NotFoundError` exception - -## Start Here -Which file to look at first and why: - -**Start with `app/lib/firehose_web/controllers/blog_controller.ex`** - -Why: This is the central controller that handles all blog requests. Understanding its structure (especially the `parse_page/1` function and `resolve_blog/2` plug) provides context for why the validation tests were added and how input handling works across both HTML and JSON endpoints. - -## Code Smells & Refactoring Suggestions - -### Smell 1: Repetitive Validation Tests -**Issue**: Four tests for page parameter validation (`page=abc`, `-1`, `0`, and valid values) are highly repetitive with identical assertions. - -**Refactoring Suggestion**: Use parameterized tests or test helpers: -```elixir -# Test helper approach -test_page_fallback("page=abc", "abc") -test_page_fallback("page=-1", "-1") -test_page_fallback("page=0", "0") - -defp test_page_fallback(query_param, expected_page) do - conn = get(conn, "/blog/engineering?#{query_param}") - assert html_response(conn, 200) =~ "Engineering Blog" -end -``` - -### Smell 2: Missing Negative Test Coverage -**Issue**: Tests don't verify what happens when invalid blog_id is provided (e.g., `/blog/invalid-blog`). - -**Refactoring Suggestion**: Add test for unknown blog: -```elixir -test "GET /blog/unknown returns 404", %{conn: conn} do - conn = get(conn, "/blog/unknown") - assert html_response(conn, 404) -end -``` - -### Smell 3: Inconsistent Test Naming -**Issue**: Some tests use hyphenated slugs (`v0-1-0`), others use different formats. The naming doesn't clearly indicate what's being tested. - -**Refactoring Suggestion**: Standardize naming: -```elixir -# Instead of: "GET /blog/releases/v0-1-0 returns HTML post" -test "GET /blog/releases/:slug returns a release post", %{conn: conn} do -``` - -### Smell 4: Redundant Layout Assertions -**Issue**: Multiple tests assert the same "firehose" string appears in response, testing layout presence. - -**Refactoring Suggestion**: Create a shared test helper: -```elixir -defp assert_has_app_layout(body), - do: assert body =~ "firehose" - -# Then in tests: assert_has_app_layout(body) -``` - -### Smell 5: Test Order Doesn't Follow Flow -**Issue**: Tests are grouped by endpoint but validation tests (which should be first for defensive programming) are in the middle. - -**Refactoring Suggestion**: Reorder to follow natural request flow: -1. Input validation (404s, invalid params) -2. Success cases (index, show, tag) -3. Edge cases (pagination, RSS feeds) - -### Smell 6: No Test for Controller-Level Error Handling -**Issue**: The controller uses `halt()` in the resolve_blog plug, but there's no test verifying this behavior. - -**Refactoring Suggestion**: Add test: -```elixir -test "GET /blog/:blog_id with invalid blog halts request", %{conn: conn} do - conn = get(conn, "/blog/invalid") - assert conn.halted -end -``` - -### Smell 7: Mixed Response Types Without Clear Separation -**Issue**: HTML tests use `html_response/2`, JSON tests use `json_response/2`, but there's no helper to verify content type before parsing. - -**Refactoring Suggestion**: Create response helpers: -```elixir -defp assert_html(conn, status), do: assert html_response(conn, status) != "" -defp assert_json(conn, status), do: assert json_response(conn, status) != %{} -``` - -### Smell 8: No Test for Concurrent Requests or Edge Cases -**Issue**: Missing tests for: -- Empty page parameter (`?page=`) -- Very large page numbers -- Special characters in slug/tag parameters - -**Refactoring Suggestion**: Add edge case tests to validation describe block. - -### Overall Recommendations -1. **Extract test helpers** to reduce duplication (especially for page validation) -2. **Standardize test naming** conventions across all blog types -3. **Add positive test** for valid page numbers (currently missing) -4. **Consider property-based testing** for input validation scenarios -5. **Add performance tests** if pagination is used heavily -6. **Create integration tests** that verify end-to-end flows \ No newline at end of file +**Tests exist**: `/workspaces/firehose/app/test/firehose_web/controllers/blog_test.exs` (lines 35-42, 117-122) verify tag page functionality. \ No newline at end of file diff --git a/new-post.md b/new-post.md new file mode 100644 index 0000000..ba21955 --- /dev/null +++ b/new-post.md @@ -0,0 +1,45 @@ +```mermaid +sequenceDiagram + participant User + participant Engineering as Engineering Folder
(priv/blog/engineering) + participant Blogex as Blogex Library + participant PhoenixApp as Firehose Web App + participant Browser + + Note over User,Browser: New Markdown File Flow + + User->>Engineering: Create markdown file
(e.g., new-post.md) + + Note over Engineering: File appears in directory + + Note over Blogex: Blogex reads markdown files at app startup
via config (priv/blog/engineering/**/*.md) + + PhoenixApp->>Blogex: Request post index via BlogController
(GET /blog/engineering) + Blogex->>Engineering: Read markdown files from priv/blog/engineering/ + Blogex->>Blogex: Parse markdown + frontmatter + Blogex->>Blogex: Create %Blogex.Post{ structs} + + Note over Blogex: Blogex renders HTML using its own
templates in blogex/components.ex (post_index, post_show) + + PhoenixApp->>PhoenixApp: Render blog_html/index.html.heex (via BlogHTML) + + Note over PhoenixApp,Browser: Individual Post Request
(GET /blog/engineering/:slug) + + Browser->>PhoenixApp: HTTP GET /blog/engineering/new-post + PhoenixApp->>PhoenixApp: FirehoseWeb.BlogController.show + PhoenixApp->>Blogex: Get post by slug + Blogex->>Engineering: Read markdown file + Blogex->>Blogex: Parse and return %Blogex.Post{} + + Note over Blogex: Blogex renders show_page for individual posts + + PhoenixApp->>PhoenixApp: Render blog_html/show.html.heex (via BlogHTML) + PhoenixApp->>PhoenixApp: Apply FirehoseWeb.Layouts.app layout + PhoenixApp->>PhoenixApp: Wrap with FirehoseWeb.Layouts.root layout + + Note over PhoenixApp: Layout provides:
- Navbar (Engineering/Releases/QWAN)
- Theme toggle
- Global CSS (app.css with Tailwind/daisyUI)
- Footer/flash messages + + PhoenixApp->>Browser: Return full HTML page + + Browser->>Browser: Render page with app styling +``` \ No newline at end of file From 5d49af27905286619335e986dce08527fe0edfda Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Fri, 20 Mar 2026 20:55:20 +0000 Subject: [PATCH 18/46] Add reusable script to refactor Phoenix test conn aliasing Portable awk-based script that transforms conn shadowing patterns into idiomatic pipe chains across 4 cases (body extraction, single assert, pattern match assert, multi-use rename). --- refactor_conn_aliasing.sh | 188 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100755 refactor_conn_aliasing.sh diff --git a/refactor_conn_aliasing.sh b/refactor_conn_aliasing.sh new file mode 100755 index 0000000..231ff01 --- /dev/null +++ b/refactor_conn_aliasing.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: refactor_conn_aliasing.sh [OPTIONS] FILE... + --dry-run Show diff without modifying files + --help Show usage +EOF +} + +DRY_RUN=false +FILES=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + *) FILES+=("$1"); shift ;; + esac +done + +if [[ ${#FILES[@]} -eq 0 ]]; then + echo "Error: no files specified" >&2 + usage >&2 + exit 1 +fi + +for file in "${FILES[@]}"; do + if [[ ! -f "$file" ]]; then + echo "Warning: $file not found, skipping" >&2 + continue + fi + + tmpfile=$(mktemp) + trap "rm -f '$tmpfile'" EXIT + + awk ' + # Detect trigger line: conn = VERB(conn, ARGS) + # where VERB is get/post/put/patch/delete/head/options + /^[[:space:]]*conn = (get|post|put|patch|delete|head|options)\(conn, / { + trigger_line = $0 + # Extract leading whitespace + match($0, /^[[:space:]]*/) + indent = substr($0, RSTART, RLENGTH) + + # Extract verb and args from: conn = verb(conn, args) + rest = $0 + sub(/^[[:space:]]*conn = /, "", rest) + # rest is now: verb(conn, args) + paren_pos = index(rest, "(") + verb = substr(rest, 1, paren_pos - 1) + # args portion: everything after "conn, " up to the trailing ")" + inner = substr(rest, paren_pos + 1) + sub(/\)$/, "", inner) + # inner is: conn, args + sub(/^conn, /, "", inner) + args = inner + + # Read the next non-blank line + triggered = 1 + next + } + + triggered == 1 { + # Skip blank lines, accumulating them + if ($0 ~ /^[[:space:]]*$/) { + blank_lines = blank_lines $0 "\n" + next + } + + next_line = $0 + triggered = 0 + + # Now look ahead: count how many subsequent lines (until scope boundary) + # reference "conn" — to decide Case 4 vs Cases 1-3 + # We already have next_line. Check if next_line references conn. + # Then peek further lines. + + # For simplicity: check if next_line matches Case 1, 2, or 3 patterns. + # If it does, check the line AFTER that for more conn references (Case 4 override). + + # Case 1: var = helper(conn, status) + # helpers: html_response, json_response, text_response, response, redirected_to + case1 = 0 + if (match(next_line, /^[[:space:]]*([a-z_]+) = (html_response|json_response|text_response|response|redirected_to)\(conn, [^)]+\)$/, m1)) { + case1 = 1 + c1_var = m1[1] + c1_helper = m1[2] + # Extract status from helper(conn, status) + match(next_line, /\(conn, ([^)]+)\)/, m1s) + c1_status = m1s[1] + } + + # Case 2: assert helper(conn, status) with optional =~ "..." + case2 = 0 + if (match(next_line, /^[[:space:]]*assert (html_response|json_response|text_response|response|redirected_to)\(conn, ([^)]+)\)(.*)$/, m2)) { + case2 = 1 + c2_helper = m2[1] + c2_status = m2[2] + c2_tail = m2[3] + } + + # Case 3: assert %{...} = helper(conn, status) + case3 = 0 + if (match(next_line, /^[[:space:]]*assert (%\{[^}]*\}) = (html_response|json_response|text_response|response|redirected_to)\(conn, ([^)]+)\)$/, m3)) { + case3 = 1 + c3_pattern = m3[1] + c3_helper = m3[2] + c3_status = m3[3] + } + + # If we matched Case 1, 2, or 3, emit the merged line + if (case1) { + print indent c1_var " = conn |> " verb "(" args ") |> " c1_helper "(" c1_status ")" + if (blank_lines != "") printf "%s", blank_lines + blank_lines = "" + next + } + if (case2) { + print indent "assert conn |> " verb "(" args ") |> " c2_helper "(" c2_status ")" c2_tail + if (blank_lines != "") printf "%s", blank_lines + blank_lines = "" + next + } + if (case3) { + print indent "assert " c3_pattern " = conn |> " verb "(" args ") |> " c3_helper "(" c3_status ")" + if (blank_lines != "") printf "%s", blank_lines + blank_lines = "" + next + } + + # If next_line references conn at all, this is Case 4 territory + # (multiple uses without a recognized single-merge pattern) + if (next_line ~ /conn/) { + # Case 4: rename to response + print indent "response = conn |> " verb "(" args ")" + if (blank_lines != "") printf "%s", blank_lines + blank_lines = "" + # Replace conn with response in next_line + gsub(/conn/, "response", next_line) + print next_line + # Continue replacing conn->response in subsequent lines until scope boundary + renaming = 1 + next + } + + # No conn reference on next line — leave trigger unchanged (fallback) + print trigger_line + if (blank_lines != "") printf "%s", blank_lines + blank_lines = "" + print next_line + next + } + + # Renaming mode for Case 4: replace conn with response until scope boundary + renaming == 1 { + # Scope boundary: blank line, "end", reduced indentation, or new conn = assignment + if ($0 ~ /^[[:space:]]*$/ || $0 ~ /^[[:space:]]*end$/ || $0 ~ /^[[:space:]]*conn =/) { + renaming = 0 + print + next + } + gsub(/conn/, "response") + print + next + } + + # Normal mode: pass through + { + if (blank_lines != "") { + printf "%s", blank_lines + blank_lines = "" + } + print + } + + BEGIN { triggered = 0; renaming = 0; blank_lines = "" } + ' "$file" > "$tmpfile" + + if $DRY_RUN; then + diff -u "$file" "$tmpfile" || true + else + mv "$tmpfile" "$file" + echo "Refactored: $file" + fi +done From c18f9cd2e3718ca7fa7f904149726abe23e96377 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Fri, 20 Mar 2026 21:01:32 +0000 Subject: [PATCH 19/46] Fix Sandbox module not available in DataCase setup The alias was inside the `using` block (only available to consumers), but setup_sandbox/1 runs in DataCase itself. Use fully qualified name. --- app/test/support/data_case.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/test/support/data_case.ex b/app/test/support/data_case.ex index 065c109..57b0cee 100644 --- a/app/test/support/data_case.ex +++ b/app/test/support/data_case.ex @@ -38,8 +38,8 @@ defmodule Firehose.DataCase do Sets up the sandbox based on the test tags. """ def setup_sandbox(tags) do - pid = Sandbox.start_owner!(Firehose.Repo, shared: not tags[:async]) - on_exit(fn -> Sandbox.stop_owner(pid) end) + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Firehose.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) end @doc """ From 9426582abc8882bbf097146456747429d9fbe4ba Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Fri, 20 Mar 2026 21:36:08 +0000 Subject: [PATCH 20/46] Refactor conn aliasing in controller tests to use pipe chains Applied refactor_conn_aliasing.sh to eliminate conn shadowing. Show draft posts in test and dev --- app/config/config.exs | 3 +- app/config/prod.exs | 3 + .../firehose_web/controllers/blog_test.exs | 98 ++++++++++--------- .../controllers/page_controller_test.exs | 3 +- app/test/support/data_case.ex | 4 +- blogex/lib/blogex.ex | 5 + blogex/lib/blogex/blog.ex | 10 +- 7 files changed, 74 insertions(+), 52 deletions(-) diff --git a/app/config/config.exs b/app/config/config.exs index 670366a..93010aa 100644 --- a/app/config/config.exs +++ b/app/config/config.exs @@ -61,7 +61,8 @@ config :logger, :default_formatter, config :phoenix, :json_library, Jason config :blogex, - blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes] + blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes], + show_drafts: true # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/app/config/prod.exs b/app/config/prod.exs index 722b45b..59afb4b 100644 --- a/app/config/prod.exs +++ b/app/config/prod.exs @@ -13,6 +13,9 @@ config :swoosh, api_client: Swoosh.ApiClient.Req # Disable Swoosh Local Memory Storage config :swoosh, local: false +# Hide draft blog posts in production +config :blogex, show_drafts: false + # Do not print debug messages in production config :logger, level: :info diff --git a/app/test/firehose_web/controllers/blog_test.exs b/app/test/firehose_web/controllers/blog_test.exs index 2a5fcb8..e9893b5 100644 --- a/app/test/firehose_web/controllers/blog_test.exs +++ b/app/test/firehose_web/controllers/blog_test.exs @@ -5,8 +5,7 @@ defmodule FirehoseWeb.BlogTest do defp visit_engineering_page(conn, suffix \\ "") do path = "/blog/engineering" <> suffix - conn = get(conn, path) - body = html_response(conn, 200) + body = conn |> get(path) |> html_response(200) assert body =~ "Engineering Blog" assert body =~ "firehose" body @@ -14,8 +13,7 @@ defmodule FirehoseWeb.BlogTest do defp visit_engineering_path(conn, suffix) do path = "/blog/engineering" <> suffix - conn = get(conn, path) - body = html_response(conn, 200) + body = conn |> get(path) |> html_response(200) assert body =~ "firehose" body end @@ -38,23 +36,19 @@ defmodule FirehoseWeb.BlogTest do describe "input validation" do test "GET /blog/nonexistent returns 404", %{conn: conn} do - conn = get(conn, "/blog/nonexistent") - assert html_response(conn, 404) + assert conn |> get("/blog/nonexistent") |> html_response(404) end test "GET /blog/engineering?page=abc falls back to page 1", %{conn: conn} do - body = visit_engineering_page(conn, "") - assert body =~ "Engineering Blog" + assert conn |> get("/blog/engineering?page=abc") |> html_response(200) =~ "Engineering Blog" end test "GET /blog/engineering?page=-1 falls back to page 1", %{conn: conn} do - body = visit_engineering_page(conn, "") - assert body =~ "Engineering Blog" + assert conn |> get("/blog/engineering?page=-1") |> html_response(200) =~ "Engineering Blog" end test "GET /blog/engineering?page=0 falls back to page 1", %{conn: conn} do - body = visit_engineering_page(conn, "?page=0") - assert body =~ "Engineering Blog" + assert conn |> get("/blog/engineering?page=0") |> html_response(200) =~ "Engineering Blog" end test "GET /blog/engineering/nonexistent-post returns 404", %{conn: conn} do @@ -66,85 +60,99 @@ defmodule FirehoseWeb.BlogTest do describe "release notes blog (HTML)" do test "GET /blog/releases returns HTML index", %{conn: conn} do - conn = get(conn, "/blog/releases") - body = html_response(conn, 200) + body = conn |> get("/blog/releases") |> html_response(200) assert body =~ "Release Notes" assert body =~ "v0.1.0 Released" end test "GET /blog/releases/:slug returns HTML post", %{conn: conn} do - conn = get(conn, "/blog/releases/v0-1-0") - body = html_response(conn, 200) + body = conn |> get("/blog/releases/v0-1-0") |> html_response(200) assert body =~ "v0.1.0 Released" end test "GET /blog/releases/tag/:tag returns HTML tag page", %{conn: conn} do - conn = get(conn, "/blog/releases/tag/elixir") - body = html_response(conn, 200) + body = conn |> get("/blog/releases/tag/elixir") |> html_response(200) assert body =~ ~s(tagged "elixir") end end describe "engineering blog (JSON API)" do test "GET /api/blog/engineering returns post index", %{conn: conn} do - conn = conn |> put_req_header("accept", "application/json") - conn = get(conn, "/api/blog/engineering") - assert %{"blog" => "engineering", "posts" => posts} = json_response(conn, 200) + assert %{"blog" => "engineering", "posts" => posts} = + conn + |> put_req_header("accept", "application/json") + |> get("/api/blog/engineering") + |> json_response(200) + assert is_list(posts) refute Enum.empty?(posts) end test "GET /api/blog/engineering/:slug returns a post", %{conn: conn} do - conn = conn |> put_req_header("accept", "application/json") - conn = get(conn, "/api/blog/engineering/hello-world") - assert %{"id" => "hello-world", "title" => "Hello World"} = json_response(conn, 200) + assert %{"id" => "hello-world", "title" => "Hello World"} = + conn + |> put_req_header("accept", "application/json") + |> get("/api/blog/engineering/hello-world") + |> json_response(200) end test "GET /api/blog/engineering/:slug returns 404 for missing post", %{conn: conn} do - conn = conn |> put_req_header("accept", "application/json") - conn = get(conn, "/api/blog/engineering/nonexistent") - assert response(conn, 404) + assert conn + |> put_req_header("accept", "application/json") + |> get("/api/blog/engineering/nonexistent") + |> response(404) end test "GET /api/blog/engineering/feed.xml returns RSS", %{conn: conn} do - conn = get(conn, "/api/blog/engineering/feed.xml") - assert response(conn, 200) =~ " get("/api/blog/engineering/feed.xml") + assert response(response, 200) =~ " put_req_header("accept", "application/json") - conn = get(conn, "/api/blog/engineering/tag/elixir") - assert %{"blog" => "engineering", "tag" => "elixir", "posts" => posts} = json_response(conn, 200) + assert %{"blog" => "engineering", "tag" => "elixir", "posts" => posts} = + conn + |> put_req_header("accept", "application/json") + |> get("/api/blog/engineering/tag/elixir") + |> json_response(200) + assert is_list(posts) end end describe "release notes blog (JSON API)" do test "GET /api/blog/releases returns post index", %{conn: conn} do - conn = conn |> put_req_header("accept", "application/json") - conn = get(conn, "/api/blog/releases") - assert %{"blog" => "release_notes", "posts" => posts} = json_response(conn, 200) + assert %{"blog" => "release_notes", "posts" => posts} = + conn + |> put_req_header("accept", "application/json") + |> get("/api/blog/releases") + |> json_response(200) + assert is_list(posts) refute Enum.empty?(posts) end test "GET /api/blog/releases/:slug returns a post", %{conn: conn} do - conn = conn |> put_req_header("accept", "application/json") - conn = get(conn, "/api/blog/releases/v0-1-0") - assert %{"id" => "v0-1-0", "title" => "v0.1.0 Released"} = json_response(conn, 200) + assert %{"id" => "v0-1-0", "title" => "v0.1.0 Released"} = + conn + |> put_req_header("accept", "application/json") + |> get("/api/blog/releases/v0-1-0") + |> json_response(200) end test "GET /api/blog/releases/feed.xml returns RSS", %{conn: conn} do - conn = get(conn, "/api/blog/releases/feed.xml") - assert response(conn, 200) =~ " get("/api/blog/releases/feed.xml") + assert response(response, 200) =~ " put_req_header("accept", "application/json") - conn = get(conn, "/api/blog/releases/tag/elixir") - assert %{"blog" => "release_notes", "tag" => "elixir", "posts" => posts} = json_response(conn, 200) + assert %{"blog" => "release_notes", "tag" => "elixir", "posts" => posts} = + conn + |> put_req_header("accept", "application/json") + |> get("/api/blog/releases/tag/elixir") + |> json_response(200) + assert is_list(posts) end end diff --git a/app/test/firehose_web/controllers/page_controller_test.exs b/app/test/firehose_web/controllers/page_controller_test.exs index 125f39d..ea981ed 100644 --- a/app/test/firehose_web/controllers/page_controller_test.exs +++ b/app/test/firehose_web/controllers/page_controller_test.exs @@ -2,8 +2,7 @@ defmodule FirehoseWeb.PageControllerTest do use FirehoseWeb.ConnCase test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - body = html_response(conn, 200) + body = conn |> get(~p"/") |> html_response(200) assert body =~ "Drinking from the firehose" assert body =~ "Willem van den Ende" end diff --git a/app/test/support/data_case.ex b/app/test/support/data_case.ex index 57b0cee..065c109 100644 --- a/app/test/support/data_case.ex +++ b/app/test/support/data_case.ex @@ -38,8 +38,8 @@ defmodule Firehose.DataCase do Sets up the sandbox based on the test tags. """ def setup_sandbox(tags) do - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Firehose.Repo, shared: not tags[:async]) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + pid = Sandbox.start_owner!(Firehose.Repo, shared: not tags[:async]) + on_exit(fn -> Sandbox.stop_owner(pid) end) end @doc """ diff --git a/blogex/lib/blogex.ex b/blogex/lib/blogex.ex index 74c0b4b..c0cf2ce 100644 --- a/blogex/lib/blogex.ex +++ b/blogex/lib/blogex.ex @@ -110,6 +110,11 @@ defmodule Blogex do * `Blogex.Router` — mountable Plug router """ + @doc "Returns true if draft posts should be visible (dev/test environments)." + def show_drafts? do + Application.get_env(:blogex, :show_drafts, false) + end + defdelegate blogs, to: Blogex.Registry defdelegate get_blog!(blog_id), to: Blogex.Registry defdelegate get_blog(blog_id), to: Blogex.Registry diff --git a/blogex/lib/blogex/blog.ex b/blogex/lib/blogex/blog.ex index 040347b..8ae265d 100644 --- a/blogex/lib/blogex/blog.ex +++ b/blogex/lib/blogex/blog.ex @@ -73,8 +73,14 @@ defmodule Blogex.Blog do @doc "Returns the base URL path for this blog." def base_path, do: @blog_base_path - @doc "Returns all published posts, newest first." - def all_posts, do: Enum.filter(@posts, & &1.published) + @doc "Returns all visible posts, newest first. Drafts are included in dev/test." + def all_posts do + if Blogex.show_drafts?() do + @posts + else + Enum.filter(@posts, & &1.published) + end + end @doc "Returns the N most recent published posts." def recent_posts(n \\ 5), do: Enum.take(all_posts(), n) From 505a2d0bd676c74d9b7723b6ce957f120524dca2 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Fri, 20 Mar 2026 21:17:42 +0000 Subject: [PATCH 21/46] Add custom Credo check for conn shadowing in tests Detects `conn = get(conn, ...)` patterns and directs to refactor_conn_aliasing.sh for automatic fixing. --- app/.credo.exs | 226 +++++++++++++++++++ app/lib/firehose/checks/no_conn_shadowing.ex | 44 ++++ 2 files changed, 270 insertions(+) create mode 100644 app/.credo.exs create mode 100644 app/lib/firehose/checks/no_conn_shadowing.ex diff --git a/app/.credo.exs b/app/.credo.exs new file mode 100644 index 0000000..5638a4b --- /dev/null +++ b/app/.credo.exs @@ -0,0 +1,226 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: ["lib/firehose/checks/"], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.StructFieldAmount, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedMapOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFilename, []}, + + # + ## Custom Checks + # + {Firehose.Checks.NoConnShadowing, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.CondInsteadOfIfElse, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + # {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/app/lib/firehose/checks/no_conn_shadowing.ex b/app/lib/firehose/checks/no_conn_shadowing.ex new file mode 100644 index 0000000..405dca5 --- /dev/null +++ b/app/lib/firehose/checks/no_conn_shadowing.ex @@ -0,0 +1,44 @@ +defmodule Firehose.Checks.NoConnShadowing do + use Credo.Check, + base_priority: :normal, + category: :readability, + explanations: [ + check: """ + Conn shadowing (`conn = get(conn, ...)`) makes Phoenix controller tests + noisy. Use pipe chains instead: + + body = conn |> get("/path") |> html_response(200) + + Run `./refactor_conn_aliasing.sh ` to fix automatically. + """ + ] + + @http_verbs ~w(get post put patch delete head options)a + + @impl true + def run(%SourceFile{} = source_file, params) do + issue_meta = IssueMeta.for(source_file, params) + + source_file + |> Credo.Code.prewalk(&traverse(&1, &2, issue_meta)) + |> Enum.reverse() + end + + defp traverse({:=, meta, [{:conn, _, _}, {verb, _, [{:conn, _, _} | _]}]} = ast, issues, issue_meta) + when verb in @http_verbs do + issue = issue_for(issue_meta, meta[:line], verb) + {ast, [issue | issues]} + end + + defp traverse(ast, issues, _issue_meta) do + {ast, issues} + end + + defp issue_for(issue_meta, line_no, verb) do + format_issue( + issue_meta, + message: "Conn shadowing detected (`conn = #{verb}(conn, ...)`). Run `./refactor_conn_aliasing.sh ` to fix.", + line_no: line_no + ) + end +end From 73e0d9cf1ee56260d3a0b9bdb9026516f1cf4949 Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Sat, 21 Mar 2026 15:36:51 +0000 Subject: [PATCH 22/46] sandboxed haiku with pi --- nono.sh | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 nono.sh diff --git a/nono.sh b/nono.sh new file mode 100644 index 0000000..c160bb9 --- /dev/null +++ b/nono.sh @@ -0,0 +1,9 @@ +#!/bin/bash +nono run \ + --profile pi \ + --allow-cwd \ + --allow /Users/willem/.local/share/mise \ + --allow /Users/willem/.pi \ + --allow /Users/willem/Library/Caches/mise \ + --allow-net \ + -- pi --verbose -p 'write a haiku' From 2708f81f1d4c27bb9fc85c99b8bb9c7919e23b71 Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Sat, 21 Mar 2026 15:39:15 +0000 Subject: [PATCH 23/46] initial setup for autoresearch of sequence diagram prompt --- sequence-diagram-skill/.gitignore | 10 ++ sequence-diagram-skill/README.md | 80 +++++++++++++ sequence-diagram-skill/autoresearch.checks.sh | 57 +++++++++ sequence-diagram-skill/autoresearch.md | 96 +++++++++++++++ sequence-diagram-skill/autoresearch.sh | 101 ++++++++++++++++ sequence-diagram-skill/benchmark/tasks.jsonl | 3 + sequence-diagram-skill/scripts/config.env | 10 ++ sequence-diagram-skill/scripts/run_one.sh | 58 ++++++++++ sequence-diagram-skill/scripts/score.sh | 109 ++++++++++++++++++ .../scripts/sidetrack_blocklist.txt | 23 ++++ sequence-diagram-skill/skill/SKILL.md | 54 +++++++++ 11 files changed, 601 insertions(+) create mode 100644 sequence-diagram-skill/.gitignore create mode 100644 sequence-diagram-skill/README.md create mode 100755 sequence-diagram-skill/autoresearch.checks.sh create mode 100644 sequence-diagram-skill/autoresearch.md create mode 100755 sequence-diagram-skill/autoresearch.sh create mode 100644 sequence-diagram-skill/benchmark/tasks.jsonl create mode 100644 sequence-diagram-skill/scripts/config.env create mode 100755 sequence-diagram-skill/scripts/run_one.sh create mode 100755 sequence-diagram-skill/scripts/score.sh create mode 100644 sequence-diagram-skill/scripts/sidetrack_blocklist.txt create mode 100644 sequence-diagram-skill/skill/SKILL.md diff --git a/sequence-diagram-skill/.gitignore b/sequence-diagram-skill/.gitignore new file mode 100644 index 0000000..7b40247 --- /dev/null +++ b/sequence-diagram-skill/.gitignore @@ -0,0 +1,10 @@ +# autoresearch session +autoresearch.jsonl +autoresearch.ideas.md + +# temp +.tmp_* +*.tmp + +# OS +.DS_Store diff --git a/sequence-diagram-skill/README.md b/sequence-diagram-skill/README.md new file mode 100644 index 0000000..c78b201 --- /dev/null +++ b/sequence-diagram-skill/README.md @@ -0,0 +1,80 @@ +# Sequence Diagram Skill — Autoresearch + +Optimizes a pi skill for generating Mermaid sequence diagrams from +Elixir/Phoenix codebases, using [pi-autoresearch](https://github.com/davebcn87/pi-autoresearch). + +## The Problem + +Small local models (Qwen3.5-35B-A3B) produce great sequence diagrams for +well-represented languages (C#, Java) but go off the rails with Elixir/Phoenix — +sidetracking into imaginary code reviews instead of finishing the diagram. + +## How It Works + +The autoresearch loop mutates `skill/SKILL.md`, runs it against 3 scenarios +from a real Phoenix codebase (Firehose), and scores with **zero-judge-model +bash evals**: + +| Eval | Check | Tool | +|------|-------|------| +| has_diagram | Output has `` ```mermaid `` + `sequenceDiagram` | grep | +| diagram_parseable | Valid mermaid syntax (participants + messages) | grep / mmdc | +| uses_real_modules | ≥2 actual module names from codebase | grep | +| uses_real_functions | ≥1 actual function name | grep | +| no_sidetracking | No review/critique language | grep against blocklist | +| concise | Under 3000 chars | wc | + +3 tasks × 6 evals = 18 max score. + +## Setup + +1. Clone the Firehose repo into `workspace/`: + ```bash + git clone https://gitea.apps.sustainabledelivery.com/mostalive/firehose workspace + ``` + +2. Make scripts executable: + ```bash + chmod +x autoresearch.sh autoresearch.checks.sh scripts/*.sh + ``` + +3. Configure model access in `scripts/config.env`: + - Local: leave `SSH_TARGET` empty, have pi configured with your model + - Remote: set `SSH_TARGET=analyst@your-host` and `SSH_PORT=2222` + +4. Init git and start: + ```bash + git init && git add -A && git commit -m "initial" + pi + # then: /autoresearch + ``` + +## Project Structure + +``` +sequence-diagram-skill/ +├── autoresearch.md # Session doc (pi reads this) +├── autoresearch.sh # Benchmark runner +├── autoresearch.checks.sh # Sanity checks on SKILL.md +├── skill/ +│ └── SKILL.md # THE FILE BEING OPTIMIZED +├── benchmark/ +│ └── tasks.jsonl # 3 test scenarios +├── scripts/ +│ ├── config.env # Endpoint config +│ ├── run_one.sh # Run pi with skill + single task +│ ├── score.sh # Score a single output (6 binary evals) +│ └── sidetrack_blocklist.txt # Phrases that indicate off-task behavior +└── workspace/ # Clone of Firehose repo (mounted/symlinked) +``` + +## Mutation Ideas for the Agent + +The autoresearch agent only edits `skill/SKILL.md`. Good mutations include: + +- Stronger "do not review" constraints +- Explicit Elixir/Phoenix vocabulary hints (NimblePublisher, module attributes) +- Output format enforcement (ONLY the mermaid block, nothing else) +- Step-by-step process instructions (read router first, then controller, etc.) +- Short generic example of a good sequence diagram +- Negative examples ("do NOT include suggestions or improvements") diff --git a/sequence-diagram-skill/autoresearch.checks.sh b/sequence-diagram-skill/autoresearch.checks.sh new file mode 100755 index 0000000..ec3e2ba --- /dev/null +++ b/sequence-diagram-skill/autoresearch.checks.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── autoresearch.checks.sh ───────────────────────────────────────────────── +# Backpressure checks for the sequence diagram skill. +# ───────────────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_FILE="${SCRIPT_DIR}/skill/SKILL.md" +ERRORS=0 + +# 1. Skill exists and is non-empty +if [[ ! -s "$SKILL_FILE" ]]; then + echo "FAIL: skill/SKILL.md is missing or empty" + ERRORS=$((ERRORS + 1)) +fi + +# 2. Skill is not trivially short +CHAR_COUNT=$(wc -c < "$SKILL_FILE" 2>/dev/null || echo "0") +if (( CHAR_COUNT < 200 )); then + echo "FAIL: skill/SKILL.md is only ${CHAR_COUNT} chars (min: 200)" + ERRORS=$((ERRORS + 1)) +fi + +# 3. Skill is not too long (rough token proxy: 1500 tokens ≈ 6000 chars) +if (( CHAR_COUNT > 6000 )); then + echo "FAIL: skill/SKILL.md is ${CHAR_COUNT} chars (max: ~6000)" + ERRORS=$((ERRORS + 1)) +fi + +# 4. Skill must contain "sequenceDiagram" or "sequence diagram" (it's a diagram skill) +if ! grep -qi 'sequence.diagram' "$SKILL_FILE" 2>/dev/null; then + echo "FAIL: skill/SKILL.md doesn't mention sequence diagrams" + ERRORS=$((ERRORS + 1)) +fi + +# 5. Skill must NOT contain Firehose-specific code (no overfitting) +for term in "BlogController" "EngineeringBlog" "Firehose" "blogex" "priv/blog"; do + if grep -q "$term" "$SKILL_FILE" 2>/dev/null; then + echo "FAIL: skill/SKILL.md contains codebase-specific term '${term}'" + ERRORS=$((ERRORS + 1)) + fi +done + +# 6. Valid UTF-8 +if ! iconv -f utf-8 -t utf-8 "$SKILL_FILE" > /dev/null 2>&1; then + echo "FAIL: skill/SKILL.md contains invalid UTF-8" + ERRORS=$((ERRORS + 1)) +fi + +if (( ERRORS > 0 )); then + echo "Checks FAILED with ${ERRORS} error(s)" + exit 1 +else + echo "All checks passed. Skill: ${CHAR_COUNT} chars." + exit 0 +fi diff --git a/sequence-diagram-skill/autoresearch.md b/sequence-diagram-skill/autoresearch.md new file mode 100644 index 0000000..e60214f --- /dev/null +++ b/sequence-diagram-skill/autoresearch.md @@ -0,0 +1,96 @@ +# Autoresearch: Sequence Diagram Skill for Elixir/Phoenix + +## Objective + +Optimize a pi skill (`skill/SKILL.md`) that generates Mermaid sequence diagrams +from Elixir/Phoenix codebases. The skill is used with a local Qwen3.5-35B-A3B +model running on CPU. The primary failure mode is **sidetracking** — the model +abandons the diagram task and starts reviewing/critiquing the code instead. + +## Primary Metric + +**score** — higher is better (0–18 scale, sum of 6 binary evals × 3 test inputs). + +## Secondary Metrics + +- **sidetrack_count** — number of test runs containing review/critique language (lower is better) +- **parse_count** — number of outputs that contain a parseable sequenceDiagram (higher is better) + +## Architecture + +Pi runs the skill against the Firehose codebase (mounted in the workspace) using +the target model. Scoring is done by bash scripts — no judge model needed. + +## The Codebase Under Test + +**Firehose** — a Phoenix blogging platform with a monorepo structure: + +- `app/` — Phoenix web app (OTP app: `:firehose`) + - `lib/firehose_web/router.ex` — routes + - `lib/firehose_web/controllers/blog_controller.ex` — blog actions + - `lib/firehose_web/controllers/page_controller.ex` — homepage + - `lib/firehose/blogs/` — blog context modules (EngineeringBlog, ReleaseNotes) +- `blogex/` — sibling library for compile-time blog engine + - `lib/blogex/blog.ex` — `use Blogex.Blog` macro (NimblePublisher) + - `lib/blogex/components.ex` — Phoenix function components (post_meta, tag_list, etc.) + - `lib/blogex/router.ex` — API/feed routes + +**Key architectural fact:** Blogex uses NimblePublisher. All blog posts are compiled +into BEAM module attributes at build time. There is NO runtime file I/O for reading +posts. Functions like `all_posts/0`, `get_post!/1`, `posts_by_tag/1` read from +`@posts` module attributes. This is the #1 thing models get wrong. + +## Test Inputs (3 scenarios) + +### 1. Click tag on post (small) +"Generate a sequence diagram for: a user on a blog post page clicks a tag link +(e.g., 'elixir'). Trace the full request from browser through to rendered response." + +### 2. Show homepage (small) +"Generate a sequence diagram for: a user visits the homepage (GET /). +Trace from browser through to rendered HTML." + +### 3. Add blog post on disk (larger, crosses compile/runtime boundary) +"Generate a sequence diagram for: a developer creates a new markdown file in +priv/blog/engineering/. Trace what happens from file creation through to the +post being visible on the blog. Include the compile-time and runtime phases." + +## Eval Criteria (6 binary checks) + +1. **has_diagram** — output contains `` ```mermaid `` and `sequenceDiagram` +2. **diagram_parseable** — the mermaid block is syntactically valid +3. **uses_real_modules** — diagram mentions at least 2 of: BlogController, EngineeringBlog, Blogex, Router, PageController +4. **uses_real_functions** — diagram mentions at least 1 of: posts_by_tag, get_post!, all_posts, paginate, resolve_blog, render +5. **no_sidetracking** — output does NOT contain code review language (see blocklist) +6. **concise** — total output is under 3000 characters + +## Files in Scope + +| File | Agent may edit? | +|------|-----------------| +| `skill/SKILL.md` | ✅ YES — the only file the agent modifies | +| `benchmark/tasks.jsonl` | ❌ NO | +| `scripts/score.sh` | ❌ NO | +| `scripts/run_one.sh` | ❌ NO | +| `scripts/sidetrack_blocklist.txt` | ❌ NO | +| `autoresearch.sh` | ❌ NO | +| `autoresearch.checks.sh` | ❌ NO | + +## Constraints + +- SKILL.md must stay under 1500 tokens. +- SKILL.md must NOT contain any code from the Firehose codebase (no overfitting). +- SKILL.md must remain generic — it should work for any Elixir/Phoenix codebase, + not just Firehose. + +## What Has Been Tried + +(autoresearch fills this in) + +## Dead Ends + +(autoresearch fills this in) + +## Key Wins + +(autoresearch fills this in) diff --git a/sequence-diagram-skill/autoresearch.sh b/sequence-diagram-skill/autoresearch.sh new file mode 100755 index 0000000..d5b7846 --- /dev/null +++ b/sequence-diagram-skill/autoresearch.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── autoresearch.sh ───────────────────────────────────────────────────────── +# Benchmark script for sequence diagram skill optimization. +# Runs all 3 test inputs, scores each, outputs METRIC lines. +# ───────────────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/scripts/config.env" 2>/dev/null || true + +# Defaults +SSH_TARGET="${SSH_TARGET:-}" +SSH_PORT="${SSH_PORT:-2222}" +export TASK_TIMEOUT="${TASK_TIMEOUT:-180}" + +# ─── Pre-checks ────────────────────────────────────────────────────────────── + +SKILL_FILE="${SCRIPT_DIR}/skill/SKILL.md" +if [[ ! -s "$SKILL_FILE" ]]; then + echo "ERROR: skill/SKILL.md is missing or empty" + exit 1 +fi + +SKILL_CHARS=$(wc -c < "$SKILL_FILE") +echo "Skill: ${SKILL_CHARS} chars" + +TASKS_FILE="${SCRIPT_DIR}/benchmark/tasks.jsonl" +if [[ ! -f "$TASKS_FILE" ]]; then + echo "ERROR: benchmark/tasks.jsonl not found" + exit 1 +fi + +echo "────────────────────────────────────────────────────" + +# ─── Run all tasks ─────────────────────────────────────────────────────────── + +TMPDIR=$(mktemp -d) +TOTAL_SCORE=0 +SIDETRACK_COUNT=0 +PARSE_COUNT=0 +TASK_COUNT=0 + +START_TIME=$(date +%s) + +while IFS= read -r line; do + TASK_ID=$(echo "$line" | jq -r '.id') + TASK_PROMPT=$(echo "$line" | jq -r '.prompt') + TASK_COUNT=$((TASK_COUNT + 1)) + + OUTPUT_FILE="${TMPDIR}/${TASK_ID}.txt" + SCORE_FILE="${TMPDIR}/${TASK_ID}.json" + + echo " [${TASK_COUNT}/3] ${TASK_ID}..." + + # Run the task + bash "${SCRIPT_DIR}/scripts/run_one.sh" \ + "$TASK_PROMPT" \ + "$OUTPUT_FILE" \ + "$SSH_TARGET" \ + "$SSH_PORT" + + # Score it + SCORE_JSON=$(bash "${SCRIPT_DIR}/scripts/score.sh" "$OUTPUT_FILE") + echo "$SCORE_JSON" > "$SCORE_FILE" + + # Extract scores + TASK_SCORE=$(echo "$SCORE_JSON" | jq -r '.score') + TASK_SIDETRACK=$(echo "$SCORE_JSON" | jq -r '.no_sidetracking') + TASK_PARSE=$(echo "$SCORE_JSON" | jq -r '.diagram_parseable') + TASK_CHARS=$(echo "$SCORE_JSON" | jq -r '.char_count') + + TOTAL_SCORE=$((TOTAL_SCORE + TASK_SCORE)) + + if (( TASK_SIDETRACK == 0 )); then + SIDETRACK_COUNT=$((SIDETRACK_COUNT + 1)) + fi + + if (( TASK_PARSE == 1 )); then + PARSE_COUNT=$((PARSE_COUNT + 1)) + fi + + echo " score=${TASK_SCORE}/6 sidetrack=$(( 1 - TASK_SIDETRACK )) parseable=${TASK_PARSE} chars=${TASK_CHARS}" + +done < "$TASKS_FILE" + +END_TIME=$(date +%s) +TOTAL_SECONDS=$((END_TIME - START_TIME)) + +# ─── Cleanup ───────────────────────────────────────────────────────────────── + +rm -rf "$TMPDIR" + +# ─── Output METRIC lines ──────────────────────────────────────────────────── + +echo "" +echo "METRIC score=${TOTAL_SCORE}" +echo "METRIC sidetrack_count=${SIDETRACK_COUNT}" +echo "METRIC parse_count=${PARSE_COUNT}" +echo "METRIC total_seconds=${TOTAL_SECONDS}" +echo "METRIC skill_chars=${SKILL_CHARS}" diff --git a/sequence-diagram-skill/benchmark/tasks.jsonl b/sequence-diagram-skill/benchmark/tasks.jsonl new file mode 100644 index 0000000..abfd16c --- /dev/null +++ b/sequence-diagram-skill/benchmark/tasks.jsonl @@ -0,0 +1,3 @@ +{"id": "click-tag", "prompt": "Generate a sequence diagram for: a user on a blog post page clicks a tag link (e.g., 'elixir'). Trace the full HTTP request from browser through the Phoenix router, controller, domain modules, templates, and back to the browser. The codebase is in /home/analyst/workspace/. Read the relevant source files first."} +{"id": "show-homepage", "prompt": "Generate a sequence diagram for: a user visits the homepage (GET /). Trace from the browser's HTTP request through the Phoenix router, controller, template rendering, layout wrapping, and back to the browser. The codebase is in /home/analyst/workspace/. Read the relevant source files first."} +{"id": "add-post", "prompt": "Generate a sequence diagram for: a developer creates a new markdown file in priv/blog/engineering/ and the post becomes visible on the blog. Trace what happens including the compile-time phase (NimblePublisher, module recompilation) and the runtime request phase. The codebase is in /home/analyst/workspace/. Read the relevant source files first."} diff --git a/sequence-diagram-skill/scripts/config.env b/sequence-diagram-skill/scripts/config.env new file mode 100644 index 0000000..6b60d19 --- /dev/null +++ b/sequence-diagram-skill/scripts/config.env @@ -0,0 +1,10 @@ +# ─── config.env ────────────────────────────────────────────────────────────── +# Leave SSH_TARGET empty to run pi locally (e.g., on your Mac). +# Set it to use the remote pi container. + +# Remote pi container (leave empty for local) +SSH_TARGET="" +SSH_PORT=2222 + +# Timeout per task (seconds) +TASK_TIMEOUT=180 diff --git a/sequence-diagram-skill/scripts/run_one.sh b/sequence-diagram-skill/scripts/run_one.sh new file mode 100755 index 0000000..b34b95c --- /dev/null +++ b/sequence-diagram-skill/scripts/run_one.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── run_one.sh ────────────────────────────────────────────────────────────── +# Run pi with the sequence-diagram skill on a single task. +# Usage: ./scripts/run_one.sh [ssh_target] [ssh_port] +# +# If ssh_target is provided, runs remotely via SSH into the pi container. +# Otherwise runs pi locally. +# ───────────────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +TASK_PROMPT="$1" +OUTPUT_FILE="$2" +SSH_TARGET="${3:-}" +SSH_PORT="${4:-2222}" +TIMEOUT="${TASK_TIMEOUT:-180}" + +SKILL_FILE="${PROJECT_DIR}/skill/SKILL.md" + +if [[ ! -f "$SKILL_FILE" ]]; then + echo "ERROR: skill/SKILL.md not found" >&2 + exit 1 +fi + +SKILL_CONTENT=$(cat "$SKILL_FILE") + +# Build the full prompt: skill instructions + task +FULL_PROMPT="## Skill Instructions + +${SKILL_CONTENT} + +## Task + +${TASK_PROMPT}" + +if [[ -n "$SSH_TARGET" ]]; then + # ─── Remote: SSH into pi container ─────────────────────────────────── + PAYLOAD=$(jq -n --arg prompt "$FULL_PROMPT" '{"prompt": $prompt}') + + ssh -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no \ + -o ConnectTimeout=10 \ + -o BatchMode=yes \ + "$SSH_TARGET" \ + "run-task --stdin --mode print --thinking off --timeout $TIMEOUT" \ + <<< "$PAYLOAD" > "$OUTPUT_FILE" 2>/dev/null +else + # ─── Local: run pi directly ────────────────────────────────────────── + timeout "${TIMEOUT}s" pi \ + --mode print \ + --no-session \ + --no-extensions \ + --thinking none \ + -p "$FULL_PROMPT" > "$OUTPUT_FILE" 2>/dev/null || true +fi diff --git a/sequence-diagram-skill/scripts/score.sh b/sequence-diagram-skill/scripts/score.sh new file mode 100755 index 0000000..1fc2417 --- /dev/null +++ b/sequence-diagram-skill/scripts/score.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── score.sh ──────────────────────────────────────────────────────────────── +# Score a single diagram output against 6 binary evals. +# Usage: ./scripts/score.sh +# Prints a JSON line with pass/fail for each eval and total score. +# ───────────────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_FILE="$1" + +if [[ ! -f "$OUTPUT_FILE" ]]; then + echo '{"error": "file not found", "score": 0}' + exit 0 +fi + +CONTENT=$(cat "$OUTPUT_FILE") +CHAR_COUNT=${#CONTENT} + +# ─── Eval 1: has_diagram ───────────────────────────────────────────────────── +# Output contains a mermaid fenced block with sequenceDiagram +has_diagram=0 +if echo "$CONTENT" | grep -q '```mermaid' && echo "$CONTENT" | grep -q 'sequenceDiagram'; then + has_diagram=1 +fi + +# ─── Eval 2: diagram_parseable ─────────────────────────────────────────────── +# Extract the mermaid block and check basic syntax +diagram_parseable=0 +if (( has_diagram == 1 )); then + # Extract mermaid block + MERMAID_BLOCK=$(echo "$CONTENT" | sed -n '/```mermaid/,/```/p' | sed '1d;$d') + + if [[ -n "$MERMAID_BLOCK" ]]; then + # Basic syntax checks: + # - Has "sequenceDiagram" keyword + # - Has at least one "participant" line + # - Has at least one "->>", "-->>", or "->>" message line + has_keyword=$(echo "$MERMAID_BLOCK" | grep -c 'sequenceDiagram' || true) + has_participant=$(echo "$MERMAID_BLOCK" | grep -c 'participant' || true) + has_message=$(echo "$MERMAID_BLOCK" | grep -cE '\->>|-->>|\->' || true) + + if (( has_keyword > 0 && has_participant > 0 && has_message > 0 )); then + diagram_parseable=1 + fi + fi + + # If mmdc (mermaid CLI) is available, use it for real validation + if command -v mmdc &> /dev/null && (( diagram_parseable == 1 )); then + TMPFILE=$(mktemp /tmp/mermaid_XXXXXX.mmd) + echo "$MERMAID_BLOCK" > "$TMPFILE" + if mmdc -i "$TMPFILE" -o /dev/null 2>/dev/null; then + diagram_parseable=1 + else + diagram_parseable=0 + fi + rm -f "$TMPFILE" + fi +fi + +# ─── Eval 3: uses_real_modules ─────────────────────────────────────────────── +# Diagram mentions at least 2 real modules from the Firehose codebase +uses_real_modules=0 +module_count=0 +for module in BlogController EngineeringBlog ReleaseNotes Blogex Router PageController Layouts; do + if echo "$CONTENT" | grep -qi "$module"; then + module_count=$((module_count + 1)) + fi +done +if (( module_count >= 2 )); then + uses_real_modules=1 +fi + +# ─── Eval 4: uses_real_functions ───────────────────────────────────────────── +# Diagram mentions at least 1 real function from the codebase +uses_real_functions=0 +for func in posts_by_tag get_post all_posts paginate resolve_blog render recent_posts; do + if echo "$CONTENT" | grep -qi "$func"; then + uses_real_functions=1 + break + fi +done + +# ─── Eval 5: no_sidetracking ──────────────────────────────────────────────── +# Output does NOT contain code review / critique language +no_sidetracking=1 +BLOCKLIST="${SCRIPT_DIR}/sidetrack_blocklist.txt" +if [[ -f "$BLOCKLIST" ]]; then + while IFS= read -r phrase; do + phrase=$(echo "$phrase" | xargs) # trim whitespace + if [[ -n "$phrase" ]] && echo "$CONTENT" | grep -qi "$phrase"; then + no_sidetracking=0 + break + fi + done < "$BLOCKLIST" +fi + +# ─── Eval 6: concise ──────────────────────────────────────────────────────── +# Total output under 3000 characters +concise=0 +if (( CHAR_COUNT < 3000 )); then + concise=1 +fi + +# ─── Total ─────────────────────────────────────────────────────────────────── +score=$((has_diagram + diagram_parseable + uses_real_modules + uses_real_functions + no_sidetracking + concise)) + +echo "{\"score\":${score},\"has_diagram\":${has_diagram},\"diagram_parseable\":${diagram_parseable},\"uses_real_modules\":${uses_real_modules},\"uses_real_functions\":${uses_real_functions},\"no_sidetracking\":${no_sidetracking},\"concise\":${concise},\"char_count\":${CHAR_COUNT}}" diff --git a/sequence-diagram-skill/scripts/sidetrack_blocklist.txt b/sequence-diagram-skill/scripts/sidetrack_blocklist.txt new file mode 100644 index 0000000..58b233c --- /dev/null +++ b/sequence-diagram-skill/scripts/sidetrack_blocklist.txt @@ -0,0 +1,23 @@ +potential issue +consider using +should be +could be improved +recommend +suggestion +improvement +code review +refactor +best practice +security concern +vulnerability +error handling could +missing error +you might want +it would be better +note that this +be aware that +one concern +problematic +anti-pattern +smell +technical debt diff --git a/sequence-diagram-skill/skill/SKILL.md b/sequence-diagram-skill/skill/SKILL.md new file mode 100644 index 0000000..39f9962 --- /dev/null +++ b/sequence-diagram-skill/skill/SKILL.md @@ -0,0 +1,54 @@ +--- +name: sequence-diagram +description: Generate a Mermaid sequence diagram showing message flow across module boundaries for an Elixir/Phoenix interaction. Use when asked to diagram, trace, or visualize a user interaction, request flow, or feature path through the codebase. +--- + +# Sequence Diagram Skill + +Generate a Mermaid `sequenceDiagram` that traces a specific user interaction +across module boundaries in an Elixir/Phoenix codebase. + +## Your Task + +Given a description of an interaction (e.g., "user clicks a tag on a blog post") +and access to the source files, produce a Mermaid sequence diagram that accurately +shows the message flow between modules. + +## Process + +1. **Identify the entry point.** What triggers this interaction? (HTTP request, + LiveView event, PubSub message, etc.) +2. **Read the router** to find which controller/live module handles the route. +3. **Read the controller/live module** to find which functions are called and + which domain modules they delegate to. +4. **Read the domain modules** to understand what they return and how. +5. **Read templates/components** if the rendering path matters. +6. **Emit the diagram.** Use `sequenceDiagram` with participants named after + actual modules. Show function calls as messages. + +## Output Format + +Respond with ONLY a fenced Mermaid code block. No preamble, no explanation, +no code review, no suggestions. Just the diagram. + +```mermaid +sequenceDiagram + participant Browser + participant Router as FirehoseWeb.Router + ... +``` + +## Rules + +- **Participants must be real modules** from the codebase. Never invent modules. +- **Messages must be real function calls** or HTTP verbs. Use the actual function + names you found in the source (e.g., `blog.posts_by_tag(tag)`, not "get posts"). +- **Show the return path.** Responses flow back: module returns data, controller + renders, browser receives HTML. +- **Distinguish compile-time from runtime.** If a module uses NimblePublisher + or module attributes, the data is compiled into the BEAM — there is no runtime + file I/O. Show this as a note, not as a message to the filesystem. +- **Stay on task.** Do NOT review the code. Do NOT suggest improvements. Do NOT + mention potential issues. Your only job is the diagram. +- **Keep it readable.** Use `Note over` for context. Use short aliases for + long module names in the participant declaration. From afc763d9d97dc8d4dbc11c3df3b92edd4e6d31b3 Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Sat, 21 Mar 2026 18:41:21 +0000 Subject: [PATCH 24/46] fix score according to claude desktop --- sequence-diagram-skill/scripts/score.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sequence-diagram-skill/scripts/score.sh b/sequence-diagram-skill/scripts/score.sh index 1fc2417..3fd4865 100755 --- a/sequence-diagram-skill/scripts/score.sh +++ b/sequence-diagram-skill/scripts/score.sh @@ -30,7 +30,7 @@ fi diagram_parseable=0 if (( has_diagram == 1 )); then # Extract mermaid block - MERMAID_BLOCK=$(echo "$CONTENT" | sed -n '/```mermaid/,/```/p' | sed '1d;$d') + MERMAID_BLOCK=$(echo "$CONTENT" | awk '/^```mermaid/{found=1;next} found && /^```$/{exit} found{print}') if [[ -n "$MERMAID_BLOCK" ]]; then # Basic syntax checks: From 87e6490f85d366be2da8e1739408a525b119aa15 Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Tue, 24 Mar 2026 12:11:23 +0000 Subject: [PATCH 25/46] post: blog triage with an llm --- .../engineering/2026/03-20-llm-simple-play.md | 3 +- .../engineering/2026/03-24-blog-triage.md | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 app/priv/blog/engineering/2026/03-24-blog-triage.md diff --git a/app/priv/blog/engineering/2026/03-20-llm-simple-play.md b/app/priv/blog/engineering/2026/03-20-llm-simple-play.md index cfd32f1..9ade2cb 100644 --- a/app/priv/blog/engineering/2026/03-20-llm-simple-play.md +++ b/app/priv/blog/engineering/2026/03-20-llm-simple-play.md @@ -1,8 +1,7 @@ - %{ title: "Coding agent from scratch - a loop with tools, not that complicated", author: "Willem van den Ende", - published: True, + published: true, tags: ~w(llm coding-agent python exercise), description: "Coding agents are not that complicated. A loop with some tools. I found an interactive tutorial that lets you experience it" } diff --git a/app/priv/blog/engineering/2026/03-24-blog-triage.md b/app/priv/blog/engineering/2026/03-24-blog-triage.md new file mode 100644 index 0000000..3e6d718 --- /dev/null +++ b/app/priv/blog/engineering/2026/03-24-blog-triage.md @@ -0,0 +1,46 @@ +%{ + title: "Blog post triage with a local coding agent", + author: "Willem van den Ende", + published: true, + tags: ~w(llm coding-agent blogging), + description: "Can a coding agent help me get some of my draft blog posts over the line? I followed a tip by Chris Parsons to find out." +} +--- + +I made a skill for a coding agent to help me get more of my draft blog posts over the line. I enjoy writing, and am somewhat fluent in it. Publishing that writing is more hit and miss, however. I often lose energy just before a piece is finished enough. I want to publish more often, and need to form a more effective habit for it. + +# What did I get out if it? + +I got a working agent 'skill' in an hour or so. I like the QWEN models for their no-bullshit approach to feedback. As it turns out, I have about 60 pages with the 'Candidate Blogpost' tag in my notes, but most of them are not more than an idea. Only some of them have enough detail to turn into a post. I am going to keep this around, prune my candidate blogposts, and add my recent clippings to the mix. + +Quite a few of my candidates were 'just links' according to the model, but as I am inspired by [Simon Willison](https://www.simonwillison.net), there is value in sharing links with a brief description on why I think they are relevant. Probably in a different category. + +# How did I develop the skill? + +I was inspired by two writings: + +- Jurgen De Smet asking [how do you write long form articles?](https://www.linkedin.com/posts/jurgendesmet_this-is-how-i-write-long-form-articles-these-share-7441394036222935040-JHmv). +- Chris Parsons suggested to [brief an agent for daily tasks](https://www.chrismdp.com/stop-prompting-start-briefing/), and use the _backbriefing_ loop from "The Art of Action" to improve them. + +I like "The Art of Action" - detailed, yet practical. So I had a chat with a frontier model to develop a skill for a local model to surface notes that are almost finished, with some suggestions to get them over the line. + +This was my initial prompt. Full chat transcript in the Further Reading section. + +#+begin_quote +https://www.chrismdp.com/stop-prompting-start-briefing/ suggests an art of action style backbriefing loop for daily work. I would like to use a local model with pi, the shitty coding agent, instead of claude code. I have trouble publishing blogposts. I have many drafts, marked as CandidateBlogPost in an org-roam directory. I wonder if I could make some kind of pi extension or skill that finds candidate blogposts, helps identify ones that are almost finished, with a suggesion on what to do next for the top 3 almost finished, and suggestions for others on what to add. Probably prioritize recency. I could run that as a cron job in the morning, and create a new daily entry (I use daily entries for org-roam) to get me starte.d Goal would be not to have AI write my posts, but help me finish in pomodori instead of days. +#+end_quote + +What I found interesting was that, maybe because I mentioned the links were in an sqlite database, claude desktop spontaneously suggested to create a bash script as part of the skill. I used to have a meta-skill to separate the deterministic parts of agent skills into scripts, but that does not seem to be necessary anymore. I prune my agent setups continuously, only keeping what is needed. + +# Tradeoffs + +Initially I planned to run this as a scheduled job, but from the development chat it emerged that backbriefing (improving the skill as we run it daily) would not work if it runs scheduled. + +I chose a local coding agent with a local model, because I don't want to share my personal notes with a cloud service, and I thought that a smaller model would be more than powerful enough. + + +## Further reading + +https://claude.ai/share/be0184d9-f2bf-41ba-b2e3-235fe9daf9fd - initial chat do develop the skill + +I will share a repository with the skill later. I think it is more instructive to have a look at the prompt, and make one for your own notes, starting from your own goals. From b3cdd93de899e8f128fa6936c9549da83d62f10c Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Tue, 24 Mar 2026 12:13:05 +0000 Subject: [PATCH 26/46] nono sandbox --- nono.sh | 1 + sequence-diagram-skill/README.md | 80 -------------------------------- 2 files changed, 1 insertion(+), 80 deletions(-) delete mode 100644 sequence-diagram-skill/README.md diff --git a/nono.sh b/nono.sh index c160bb9..a52d41c 100644 --- a/nono.sh +++ b/nono.sh @@ -4,6 +4,7 @@ nono run \ --allow-cwd \ --allow /Users/willem/.local/share/mise \ --allow /Users/willem/.pi \ + --read /Users/willem/.git \ --allow /Users/willem/Library/Caches/mise \ --allow-net \ -- pi --verbose -p 'write a haiku' diff --git a/sequence-diagram-skill/README.md b/sequence-diagram-skill/README.md deleted file mode 100644 index c78b201..0000000 --- a/sequence-diagram-skill/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Sequence Diagram Skill — Autoresearch - -Optimizes a pi skill for generating Mermaid sequence diagrams from -Elixir/Phoenix codebases, using [pi-autoresearch](https://github.com/davebcn87/pi-autoresearch). - -## The Problem - -Small local models (Qwen3.5-35B-A3B) produce great sequence diagrams for -well-represented languages (C#, Java) but go off the rails with Elixir/Phoenix — -sidetracking into imaginary code reviews instead of finishing the diagram. - -## How It Works - -The autoresearch loop mutates `skill/SKILL.md`, runs it against 3 scenarios -from a real Phoenix codebase (Firehose), and scores with **zero-judge-model -bash evals**: - -| Eval | Check | Tool | -|------|-------|------| -| has_diagram | Output has `` ```mermaid `` + `sequenceDiagram` | grep | -| diagram_parseable | Valid mermaid syntax (participants + messages) | grep / mmdc | -| uses_real_modules | ≥2 actual module names from codebase | grep | -| uses_real_functions | ≥1 actual function name | grep | -| no_sidetracking | No review/critique language | grep against blocklist | -| concise | Under 3000 chars | wc | - -3 tasks × 6 evals = 18 max score. - -## Setup - -1. Clone the Firehose repo into `workspace/`: - ```bash - git clone https://gitea.apps.sustainabledelivery.com/mostalive/firehose workspace - ``` - -2. Make scripts executable: - ```bash - chmod +x autoresearch.sh autoresearch.checks.sh scripts/*.sh - ``` - -3. Configure model access in `scripts/config.env`: - - Local: leave `SSH_TARGET` empty, have pi configured with your model - - Remote: set `SSH_TARGET=analyst@your-host` and `SSH_PORT=2222` - -4. Init git and start: - ```bash - git init && git add -A && git commit -m "initial" - pi - # then: /autoresearch - ``` - -## Project Structure - -``` -sequence-diagram-skill/ -├── autoresearch.md # Session doc (pi reads this) -├── autoresearch.sh # Benchmark runner -├── autoresearch.checks.sh # Sanity checks on SKILL.md -├── skill/ -│ └── SKILL.md # THE FILE BEING OPTIMIZED -├── benchmark/ -│ └── tasks.jsonl # 3 test scenarios -├── scripts/ -│ ├── config.env # Endpoint config -│ ├── run_one.sh # Run pi with skill + single task -│ ├── score.sh # Score a single output (6 binary evals) -│ └── sidetrack_blocklist.txt # Phrases that indicate off-task behavior -└── workspace/ # Clone of Firehose repo (mounted/symlinked) -``` - -## Mutation Ideas for the Agent - -The autoresearch agent only edits `skill/SKILL.md`. Good mutations include: - -- Stronger "do not review" constraints -- Explicit Elixir/Phoenix vocabulary hints (NimblePublisher, module attributes) -- Output format enforcement (ONLY the mermaid block, nothing else) -- Step-by-step process instructions (read router first, then controller, etc.) -- Short generic example of a good sequence diagram -- Negative examples ("do NOT include suggestions or improvements") From fddbb4e77796327a83a35154fb38a823d46ee025 Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Tue, 24 Mar 2026 12:14:01 +0000 Subject: [PATCH 27/46] remove sequence diagram skill, moved to other repo --- sequence-diagram-skill/.gitignore | 10 -- sequence-diagram-skill/autoresearch.checks.sh | 57 --------- sequence-diagram-skill/autoresearch.md | 96 --------------- sequence-diagram-skill/autoresearch.sh | 101 ---------------- sequence-diagram-skill/benchmark/tasks.jsonl | 3 - sequence-diagram-skill/scripts/config.env | 10 -- sequence-diagram-skill/scripts/run_one.sh | 58 ---------- sequence-diagram-skill/scripts/score.sh | 109 ------------------ .../scripts/sidetrack_blocklist.txt | 23 ---- sequence-diagram-skill/skill/SKILL.md | 54 --------- 10 files changed, 521 deletions(-) delete mode 100644 sequence-diagram-skill/.gitignore delete mode 100755 sequence-diagram-skill/autoresearch.checks.sh delete mode 100644 sequence-diagram-skill/autoresearch.md delete mode 100755 sequence-diagram-skill/autoresearch.sh delete mode 100644 sequence-diagram-skill/benchmark/tasks.jsonl delete mode 100644 sequence-diagram-skill/scripts/config.env delete mode 100755 sequence-diagram-skill/scripts/run_one.sh delete mode 100755 sequence-diagram-skill/scripts/score.sh delete mode 100644 sequence-diagram-skill/scripts/sidetrack_blocklist.txt delete mode 100644 sequence-diagram-skill/skill/SKILL.md diff --git a/sequence-diagram-skill/.gitignore b/sequence-diagram-skill/.gitignore deleted file mode 100644 index 7b40247..0000000 --- a/sequence-diagram-skill/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# autoresearch session -autoresearch.jsonl -autoresearch.ideas.md - -# temp -.tmp_* -*.tmp - -# OS -.DS_Store diff --git a/sequence-diagram-skill/autoresearch.checks.sh b/sequence-diagram-skill/autoresearch.checks.sh deleted file mode 100755 index ec3e2ba..0000000 --- a/sequence-diagram-skill/autoresearch.checks.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ─── autoresearch.checks.sh ───────────────────────────────────────────────── -# Backpressure checks for the sequence diagram skill. -# ───────────────────────────────────────────────────────────────────────────── - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SKILL_FILE="${SCRIPT_DIR}/skill/SKILL.md" -ERRORS=0 - -# 1. Skill exists and is non-empty -if [[ ! -s "$SKILL_FILE" ]]; then - echo "FAIL: skill/SKILL.md is missing or empty" - ERRORS=$((ERRORS + 1)) -fi - -# 2. Skill is not trivially short -CHAR_COUNT=$(wc -c < "$SKILL_FILE" 2>/dev/null || echo "0") -if (( CHAR_COUNT < 200 )); then - echo "FAIL: skill/SKILL.md is only ${CHAR_COUNT} chars (min: 200)" - ERRORS=$((ERRORS + 1)) -fi - -# 3. Skill is not too long (rough token proxy: 1500 tokens ≈ 6000 chars) -if (( CHAR_COUNT > 6000 )); then - echo "FAIL: skill/SKILL.md is ${CHAR_COUNT} chars (max: ~6000)" - ERRORS=$((ERRORS + 1)) -fi - -# 4. Skill must contain "sequenceDiagram" or "sequence diagram" (it's a diagram skill) -if ! grep -qi 'sequence.diagram' "$SKILL_FILE" 2>/dev/null; then - echo "FAIL: skill/SKILL.md doesn't mention sequence diagrams" - ERRORS=$((ERRORS + 1)) -fi - -# 5. Skill must NOT contain Firehose-specific code (no overfitting) -for term in "BlogController" "EngineeringBlog" "Firehose" "blogex" "priv/blog"; do - if grep -q "$term" "$SKILL_FILE" 2>/dev/null; then - echo "FAIL: skill/SKILL.md contains codebase-specific term '${term}'" - ERRORS=$((ERRORS + 1)) - fi -done - -# 6. Valid UTF-8 -if ! iconv -f utf-8 -t utf-8 "$SKILL_FILE" > /dev/null 2>&1; then - echo "FAIL: skill/SKILL.md contains invalid UTF-8" - ERRORS=$((ERRORS + 1)) -fi - -if (( ERRORS > 0 )); then - echo "Checks FAILED with ${ERRORS} error(s)" - exit 1 -else - echo "All checks passed. Skill: ${CHAR_COUNT} chars." - exit 0 -fi diff --git a/sequence-diagram-skill/autoresearch.md b/sequence-diagram-skill/autoresearch.md deleted file mode 100644 index e60214f..0000000 --- a/sequence-diagram-skill/autoresearch.md +++ /dev/null @@ -1,96 +0,0 @@ -# Autoresearch: Sequence Diagram Skill for Elixir/Phoenix - -## Objective - -Optimize a pi skill (`skill/SKILL.md`) that generates Mermaid sequence diagrams -from Elixir/Phoenix codebases. The skill is used with a local Qwen3.5-35B-A3B -model running on CPU. The primary failure mode is **sidetracking** — the model -abandons the diagram task and starts reviewing/critiquing the code instead. - -## Primary Metric - -**score** — higher is better (0–18 scale, sum of 6 binary evals × 3 test inputs). - -## Secondary Metrics - -- **sidetrack_count** — number of test runs containing review/critique language (lower is better) -- **parse_count** — number of outputs that contain a parseable sequenceDiagram (higher is better) - -## Architecture - -Pi runs the skill against the Firehose codebase (mounted in the workspace) using -the target model. Scoring is done by bash scripts — no judge model needed. - -## The Codebase Under Test - -**Firehose** — a Phoenix blogging platform with a monorepo structure: - -- `app/` — Phoenix web app (OTP app: `:firehose`) - - `lib/firehose_web/router.ex` — routes - - `lib/firehose_web/controllers/blog_controller.ex` — blog actions - - `lib/firehose_web/controllers/page_controller.ex` — homepage - - `lib/firehose/blogs/` — blog context modules (EngineeringBlog, ReleaseNotes) -- `blogex/` — sibling library for compile-time blog engine - - `lib/blogex/blog.ex` — `use Blogex.Blog` macro (NimblePublisher) - - `lib/blogex/components.ex` — Phoenix function components (post_meta, tag_list, etc.) - - `lib/blogex/router.ex` — API/feed routes - -**Key architectural fact:** Blogex uses NimblePublisher. All blog posts are compiled -into BEAM module attributes at build time. There is NO runtime file I/O for reading -posts. Functions like `all_posts/0`, `get_post!/1`, `posts_by_tag/1` read from -`@posts` module attributes. This is the #1 thing models get wrong. - -## Test Inputs (3 scenarios) - -### 1. Click tag on post (small) -"Generate a sequence diagram for: a user on a blog post page clicks a tag link -(e.g., 'elixir'). Trace the full request from browser through to rendered response." - -### 2. Show homepage (small) -"Generate a sequence diagram for: a user visits the homepage (GET /). -Trace from browser through to rendered HTML." - -### 3. Add blog post on disk (larger, crosses compile/runtime boundary) -"Generate a sequence diagram for: a developer creates a new markdown file in -priv/blog/engineering/. Trace what happens from file creation through to the -post being visible on the blog. Include the compile-time and runtime phases." - -## Eval Criteria (6 binary checks) - -1. **has_diagram** — output contains `` ```mermaid `` and `sequenceDiagram` -2. **diagram_parseable** — the mermaid block is syntactically valid -3. **uses_real_modules** — diagram mentions at least 2 of: BlogController, EngineeringBlog, Blogex, Router, PageController -4. **uses_real_functions** — diagram mentions at least 1 of: posts_by_tag, get_post!, all_posts, paginate, resolve_blog, render -5. **no_sidetracking** — output does NOT contain code review language (see blocklist) -6. **concise** — total output is under 3000 characters - -## Files in Scope - -| File | Agent may edit? | -|------|-----------------| -| `skill/SKILL.md` | ✅ YES — the only file the agent modifies | -| `benchmark/tasks.jsonl` | ❌ NO | -| `scripts/score.sh` | ❌ NO | -| `scripts/run_one.sh` | ❌ NO | -| `scripts/sidetrack_blocklist.txt` | ❌ NO | -| `autoresearch.sh` | ❌ NO | -| `autoresearch.checks.sh` | ❌ NO | - -## Constraints - -- SKILL.md must stay under 1500 tokens. -- SKILL.md must NOT contain any code from the Firehose codebase (no overfitting). -- SKILL.md must remain generic — it should work for any Elixir/Phoenix codebase, - not just Firehose. - -## What Has Been Tried - -(autoresearch fills this in) - -## Dead Ends - -(autoresearch fills this in) - -## Key Wins - -(autoresearch fills this in) diff --git a/sequence-diagram-skill/autoresearch.sh b/sequence-diagram-skill/autoresearch.sh deleted file mode 100755 index d5b7846..0000000 --- a/sequence-diagram-skill/autoresearch.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ─── autoresearch.sh ───────────────────────────────────────────────────────── -# Benchmark script for sequence diagram skill optimization. -# Runs all 3 test inputs, scores each, outputs METRIC lines. -# ───────────────────────────────────────────────────────────────────────────── - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/scripts/config.env" 2>/dev/null || true - -# Defaults -SSH_TARGET="${SSH_TARGET:-}" -SSH_PORT="${SSH_PORT:-2222}" -export TASK_TIMEOUT="${TASK_TIMEOUT:-180}" - -# ─── Pre-checks ────────────────────────────────────────────────────────────── - -SKILL_FILE="${SCRIPT_DIR}/skill/SKILL.md" -if [[ ! -s "$SKILL_FILE" ]]; then - echo "ERROR: skill/SKILL.md is missing or empty" - exit 1 -fi - -SKILL_CHARS=$(wc -c < "$SKILL_FILE") -echo "Skill: ${SKILL_CHARS} chars" - -TASKS_FILE="${SCRIPT_DIR}/benchmark/tasks.jsonl" -if [[ ! -f "$TASKS_FILE" ]]; then - echo "ERROR: benchmark/tasks.jsonl not found" - exit 1 -fi - -echo "────────────────────────────────────────────────────" - -# ─── Run all tasks ─────────────────────────────────────────────────────────── - -TMPDIR=$(mktemp -d) -TOTAL_SCORE=0 -SIDETRACK_COUNT=0 -PARSE_COUNT=0 -TASK_COUNT=0 - -START_TIME=$(date +%s) - -while IFS= read -r line; do - TASK_ID=$(echo "$line" | jq -r '.id') - TASK_PROMPT=$(echo "$line" | jq -r '.prompt') - TASK_COUNT=$((TASK_COUNT + 1)) - - OUTPUT_FILE="${TMPDIR}/${TASK_ID}.txt" - SCORE_FILE="${TMPDIR}/${TASK_ID}.json" - - echo " [${TASK_COUNT}/3] ${TASK_ID}..." - - # Run the task - bash "${SCRIPT_DIR}/scripts/run_one.sh" \ - "$TASK_PROMPT" \ - "$OUTPUT_FILE" \ - "$SSH_TARGET" \ - "$SSH_PORT" - - # Score it - SCORE_JSON=$(bash "${SCRIPT_DIR}/scripts/score.sh" "$OUTPUT_FILE") - echo "$SCORE_JSON" > "$SCORE_FILE" - - # Extract scores - TASK_SCORE=$(echo "$SCORE_JSON" | jq -r '.score') - TASK_SIDETRACK=$(echo "$SCORE_JSON" | jq -r '.no_sidetracking') - TASK_PARSE=$(echo "$SCORE_JSON" | jq -r '.diagram_parseable') - TASK_CHARS=$(echo "$SCORE_JSON" | jq -r '.char_count') - - TOTAL_SCORE=$((TOTAL_SCORE + TASK_SCORE)) - - if (( TASK_SIDETRACK == 0 )); then - SIDETRACK_COUNT=$((SIDETRACK_COUNT + 1)) - fi - - if (( TASK_PARSE == 1 )); then - PARSE_COUNT=$((PARSE_COUNT + 1)) - fi - - echo " score=${TASK_SCORE}/6 sidetrack=$(( 1 - TASK_SIDETRACK )) parseable=${TASK_PARSE} chars=${TASK_CHARS}" - -done < "$TASKS_FILE" - -END_TIME=$(date +%s) -TOTAL_SECONDS=$((END_TIME - START_TIME)) - -# ─── Cleanup ───────────────────────────────────────────────────────────────── - -rm -rf "$TMPDIR" - -# ─── Output METRIC lines ──────────────────────────────────────────────────── - -echo "" -echo "METRIC score=${TOTAL_SCORE}" -echo "METRIC sidetrack_count=${SIDETRACK_COUNT}" -echo "METRIC parse_count=${PARSE_COUNT}" -echo "METRIC total_seconds=${TOTAL_SECONDS}" -echo "METRIC skill_chars=${SKILL_CHARS}" diff --git a/sequence-diagram-skill/benchmark/tasks.jsonl b/sequence-diagram-skill/benchmark/tasks.jsonl deleted file mode 100644 index abfd16c..0000000 --- a/sequence-diagram-skill/benchmark/tasks.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"id": "click-tag", "prompt": "Generate a sequence diagram for: a user on a blog post page clicks a tag link (e.g., 'elixir'). Trace the full HTTP request from browser through the Phoenix router, controller, domain modules, templates, and back to the browser. The codebase is in /home/analyst/workspace/. Read the relevant source files first."} -{"id": "show-homepage", "prompt": "Generate a sequence diagram for: a user visits the homepage (GET /). Trace from the browser's HTTP request through the Phoenix router, controller, template rendering, layout wrapping, and back to the browser. The codebase is in /home/analyst/workspace/. Read the relevant source files first."} -{"id": "add-post", "prompt": "Generate a sequence diagram for: a developer creates a new markdown file in priv/blog/engineering/ and the post becomes visible on the blog. Trace what happens including the compile-time phase (NimblePublisher, module recompilation) and the runtime request phase. The codebase is in /home/analyst/workspace/. Read the relevant source files first."} diff --git a/sequence-diagram-skill/scripts/config.env b/sequence-diagram-skill/scripts/config.env deleted file mode 100644 index 6b60d19..0000000 --- a/sequence-diagram-skill/scripts/config.env +++ /dev/null @@ -1,10 +0,0 @@ -# ─── config.env ────────────────────────────────────────────────────────────── -# Leave SSH_TARGET empty to run pi locally (e.g., on your Mac). -# Set it to use the remote pi container. - -# Remote pi container (leave empty for local) -SSH_TARGET="" -SSH_PORT=2222 - -# Timeout per task (seconds) -TASK_TIMEOUT=180 diff --git a/sequence-diagram-skill/scripts/run_one.sh b/sequence-diagram-skill/scripts/run_one.sh deleted file mode 100755 index b34b95c..0000000 --- a/sequence-diagram-skill/scripts/run_one.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ─── run_one.sh ────────────────────────────────────────────────────────────── -# Run pi with the sequence-diagram skill on a single task. -# Usage: ./scripts/run_one.sh [ssh_target] [ssh_port] -# -# If ssh_target is provided, runs remotely via SSH into the pi container. -# Otherwise runs pi locally. -# ───────────────────────────────────────────────────────────────────────────── - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" - -TASK_PROMPT="$1" -OUTPUT_FILE="$2" -SSH_TARGET="${3:-}" -SSH_PORT="${4:-2222}" -TIMEOUT="${TASK_TIMEOUT:-180}" - -SKILL_FILE="${PROJECT_DIR}/skill/SKILL.md" - -if [[ ! -f "$SKILL_FILE" ]]; then - echo "ERROR: skill/SKILL.md not found" >&2 - exit 1 -fi - -SKILL_CONTENT=$(cat "$SKILL_FILE") - -# Build the full prompt: skill instructions + task -FULL_PROMPT="## Skill Instructions - -${SKILL_CONTENT} - -## Task - -${TASK_PROMPT}" - -if [[ -n "$SSH_TARGET" ]]; then - # ─── Remote: SSH into pi container ─────────────────────────────────── - PAYLOAD=$(jq -n --arg prompt "$FULL_PROMPT" '{"prompt": $prompt}') - - ssh -p "$SSH_PORT" \ - -o StrictHostKeyChecking=no \ - -o ConnectTimeout=10 \ - -o BatchMode=yes \ - "$SSH_TARGET" \ - "run-task --stdin --mode print --thinking off --timeout $TIMEOUT" \ - <<< "$PAYLOAD" > "$OUTPUT_FILE" 2>/dev/null -else - # ─── Local: run pi directly ────────────────────────────────────────── - timeout "${TIMEOUT}s" pi \ - --mode print \ - --no-session \ - --no-extensions \ - --thinking none \ - -p "$FULL_PROMPT" > "$OUTPUT_FILE" 2>/dev/null || true -fi diff --git a/sequence-diagram-skill/scripts/score.sh b/sequence-diagram-skill/scripts/score.sh deleted file mode 100755 index 3fd4865..0000000 --- a/sequence-diagram-skill/scripts/score.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ─── score.sh ──────────────────────────────────────────────────────────────── -# Score a single diagram output against 6 binary evals. -# Usage: ./scripts/score.sh -# Prints a JSON line with pass/fail for each eval and total score. -# ───────────────────────────────────────────────────────────────────────────── - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -OUTPUT_FILE="$1" - -if [[ ! -f "$OUTPUT_FILE" ]]; then - echo '{"error": "file not found", "score": 0}' - exit 0 -fi - -CONTENT=$(cat "$OUTPUT_FILE") -CHAR_COUNT=${#CONTENT} - -# ─── Eval 1: has_diagram ───────────────────────────────────────────────────── -# Output contains a mermaid fenced block with sequenceDiagram -has_diagram=0 -if echo "$CONTENT" | grep -q '```mermaid' && echo "$CONTENT" | grep -q 'sequenceDiagram'; then - has_diagram=1 -fi - -# ─── Eval 2: diagram_parseable ─────────────────────────────────────────────── -# Extract the mermaid block and check basic syntax -diagram_parseable=0 -if (( has_diagram == 1 )); then - # Extract mermaid block - MERMAID_BLOCK=$(echo "$CONTENT" | awk '/^```mermaid/{found=1;next} found && /^```$/{exit} found{print}') - - if [[ -n "$MERMAID_BLOCK" ]]; then - # Basic syntax checks: - # - Has "sequenceDiagram" keyword - # - Has at least one "participant" line - # - Has at least one "->>", "-->>", or "->>" message line - has_keyword=$(echo "$MERMAID_BLOCK" | grep -c 'sequenceDiagram' || true) - has_participant=$(echo "$MERMAID_BLOCK" | grep -c 'participant' || true) - has_message=$(echo "$MERMAID_BLOCK" | grep -cE '\->>|-->>|\->' || true) - - if (( has_keyword > 0 && has_participant > 0 && has_message > 0 )); then - diagram_parseable=1 - fi - fi - - # If mmdc (mermaid CLI) is available, use it for real validation - if command -v mmdc &> /dev/null && (( diagram_parseable == 1 )); then - TMPFILE=$(mktemp /tmp/mermaid_XXXXXX.mmd) - echo "$MERMAID_BLOCK" > "$TMPFILE" - if mmdc -i "$TMPFILE" -o /dev/null 2>/dev/null; then - diagram_parseable=1 - else - diagram_parseable=0 - fi - rm -f "$TMPFILE" - fi -fi - -# ─── Eval 3: uses_real_modules ─────────────────────────────────────────────── -# Diagram mentions at least 2 real modules from the Firehose codebase -uses_real_modules=0 -module_count=0 -for module in BlogController EngineeringBlog ReleaseNotes Blogex Router PageController Layouts; do - if echo "$CONTENT" | grep -qi "$module"; then - module_count=$((module_count + 1)) - fi -done -if (( module_count >= 2 )); then - uses_real_modules=1 -fi - -# ─── Eval 4: uses_real_functions ───────────────────────────────────────────── -# Diagram mentions at least 1 real function from the codebase -uses_real_functions=0 -for func in posts_by_tag get_post all_posts paginate resolve_blog render recent_posts; do - if echo "$CONTENT" | grep -qi "$func"; then - uses_real_functions=1 - break - fi -done - -# ─── Eval 5: no_sidetracking ──────────────────────────────────────────────── -# Output does NOT contain code review / critique language -no_sidetracking=1 -BLOCKLIST="${SCRIPT_DIR}/sidetrack_blocklist.txt" -if [[ -f "$BLOCKLIST" ]]; then - while IFS= read -r phrase; do - phrase=$(echo "$phrase" | xargs) # trim whitespace - if [[ -n "$phrase" ]] && echo "$CONTENT" | grep -qi "$phrase"; then - no_sidetracking=0 - break - fi - done < "$BLOCKLIST" -fi - -# ─── Eval 6: concise ──────────────────────────────────────────────────────── -# Total output under 3000 characters -concise=0 -if (( CHAR_COUNT < 3000 )); then - concise=1 -fi - -# ─── Total ─────────────────────────────────────────────────────────────────── -score=$((has_diagram + diagram_parseable + uses_real_modules + uses_real_functions + no_sidetracking + concise)) - -echo "{\"score\":${score},\"has_diagram\":${has_diagram},\"diagram_parseable\":${diagram_parseable},\"uses_real_modules\":${uses_real_modules},\"uses_real_functions\":${uses_real_functions},\"no_sidetracking\":${no_sidetracking},\"concise\":${concise},\"char_count\":${CHAR_COUNT}}" diff --git a/sequence-diagram-skill/scripts/sidetrack_blocklist.txt b/sequence-diagram-skill/scripts/sidetrack_blocklist.txt deleted file mode 100644 index 58b233c..0000000 --- a/sequence-diagram-skill/scripts/sidetrack_blocklist.txt +++ /dev/null @@ -1,23 +0,0 @@ -potential issue -consider using -should be -could be improved -recommend -suggestion -improvement -code review -refactor -best practice -security concern -vulnerability -error handling could -missing error -you might want -it would be better -note that this -be aware that -one concern -problematic -anti-pattern -smell -technical debt diff --git a/sequence-diagram-skill/skill/SKILL.md b/sequence-diagram-skill/skill/SKILL.md deleted file mode 100644 index 39f9962..0000000 --- a/sequence-diagram-skill/skill/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: sequence-diagram -description: Generate a Mermaid sequence diagram showing message flow across module boundaries for an Elixir/Phoenix interaction. Use when asked to diagram, trace, or visualize a user interaction, request flow, or feature path through the codebase. ---- - -# Sequence Diagram Skill - -Generate a Mermaid `sequenceDiagram` that traces a specific user interaction -across module boundaries in an Elixir/Phoenix codebase. - -## Your Task - -Given a description of an interaction (e.g., "user clicks a tag on a blog post") -and access to the source files, produce a Mermaid sequence diagram that accurately -shows the message flow between modules. - -## Process - -1. **Identify the entry point.** What triggers this interaction? (HTTP request, - LiveView event, PubSub message, etc.) -2. **Read the router** to find which controller/live module handles the route. -3. **Read the controller/live module** to find which functions are called and - which domain modules they delegate to. -4. **Read the domain modules** to understand what they return and how. -5. **Read templates/components** if the rendering path matters. -6. **Emit the diagram.** Use `sequenceDiagram` with participants named after - actual modules. Show function calls as messages. - -## Output Format - -Respond with ONLY a fenced Mermaid code block. No preamble, no explanation, -no code review, no suggestions. Just the diagram. - -```mermaid -sequenceDiagram - participant Browser - participant Router as FirehoseWeb.Router - ... -``` - -## Rules - -- **Participants must be real modules** from the codebase. Never invent modules. -- **Messages must be real function calls** or HTTP verbs. Use the actual function - names you found in the source (e.g., `blog.posts_by_tag(tag)`, not "get posts"). -- **Show the return path.** Responses flow back: module returns data, controller - renders, browser receives HTML. -- **Distinguish compile-time from runtime.** If a module uses NimblePublisher - or module attributes, the data is compiled into the BEAM — there is no runtime - file I/O. Show this as a note, not as a message to the filesystem. -- **Stay on task.** Do NOT review the code. Do NOT suggest improvements. Do NOT - mention potential issues. Your only job is the diagram. -- **Keep it readable.** Use `Note over` for context. Use short aliases for - long module names in the participant declaration. From 590dd4a2654e10a1f5db02d87452b23b7f81d49d Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Tue, 24 Mar 2026 14:21:22 +0000 Subject: [PATCH 28/46] Fix trailing newline and format code --- .nono.sh.swp | Bin 0 -> 12288 bytes Makefile | 4 +- app/lib/firehose/checks/no_conn_shadowing.ex | 9 ++- .../controllers/blog_tags_test.exs | 54 +++++++++--------- autoresearch.jsonl | 1 + nono.sh | 3 +- 6 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 .nono.sh.swp create mode 100644 autoresearch.jsonl diff --git a/.nono.sh.swp b/.nono.sh.swp new file mode 100644 index 0000000000000000000000000000000000000000..19eb61c50bb66d93d3f8a427c8b213d5bf1d840e GIT binary patch literal 12288 zcmeI&Jx;?g6bEpF*bpCr3%D&(SrV7Bz>EY!g0V~tIEm9(xN+q4i=`FkfIDypCXN6T zVr1t4ywD&J5>?k$|41*6mFKVD7Uczvi_>#{=*vW#(;*205SUe<*Nwe{wbp)f zuTc!CRV#dZt2OIrBHk3xtww?OD&SaP2AS%aZ&c@oP;`EoSsI2F%m(pXc MA_AR?KvJRoC)|vN>i_@% literal 0 HcmV?d00001 diff --git a/Makefile b/Makefile index 6d8433d..ae5b618 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ # Common check target that runs all static analysis check: @echo "Running static analysis..." - @make -C app MISE_BIN=/home/vscode/.local/bin/mise check + @make -C app MISE_BIN=mise check # Precommit target for CI/pre-commit hooks precommit: check @@ -24,4 +24,4 @@ test: # Format code format: - @make -C app format \ No newline at end of file + @make -C app format diff --git a/app/lib/firehose/checks/no_conn_shadowing.ex b/app/lib/firehose/checks/no_conn_shadowing.ex index 405dca5..c1a94f2 100644 --- a/app/lib/firehose/checks/no_conn_shadowing.ex +++ b/app/lib/firehose/checks/no_conn_shadowing.ex @@ -24,7 +24,11 @@ defmodule Firehose.Checks.NoConnShadowing do |> Enum.reverse() end - defp traverse({:=, meta, [{:conn, _, _}, {verb, _, [{:conn, _, _} | _]}]} = ast, issues, issue_meta) + defp traverse( + {:=, meta, [{:conn, _, _}, {verb, _, [{:conn, _, _} | _]}]} = ast, + issues, + issue_meta + ) when verb in @http_verbs do issue = issue_for(issue_meta, meta[:line], verb) {ast, [issue | issues]} @@ -37,7 +41,8 @@ defmodule Firehose.Checks.NoConnShadowing do defp issue_for(issue_meta, line_no, verb) do format_issue( issue_meta, - message: "Conn shadowing detected (`conn = #{verb}(conn, ...)`). Run `./refactor_conn_aliasing.sh ` to fix.", + message: + "Conn shadowing detected (`conn = #{verb}(conn, ...)`). Run `./refactor_conn_aliasing.sh ` to fix.", line_no: line_no ) end diff --git a/app/test/firehose_web/controllers/blog_tags_test.exs b/app/test/firehose_web/controllers/blog_tags_test.exs index 51b8b15..a7f9f1f 100644 --- a/app/test/firehose_web/controllers/blog_tags_test.exs +++ b/app/test/firehose_web/controllers/blog_tags_test.exs @@ -3,8 +3,8 @@ defmodule FirehoseWeb.BlogTagsTest do defp goto_engineering_tag_page(conn, tag) do path = "/blog/engineering/tag/#{tag}" - conn = get(conn, path) - body = html_response(conn, 200) + conn_res = get(conn, path) + body = html_response(conn_res, 200) assert body =~ ~s(tagged "#{tag}") assert body =~ "Engineering Blog" body @@ -12,8 +12,8 @@ defmodule FirehoseWeb.BlogTagsTest do defp goto_releases_tag_page(conn, tag) do path = "/blog/releases/tag/#{tag}" - conn = get(conn, path) - body = html_response(conn, 200) + conn_res = get(conn, path) + body = html_response(conn_res, 200) assert body =~ ~s(tagged "#{tag}") assert body =~ "Release Notes" body @@ -33,8 +33,8 @@ defmodule FirehoseWeb.BlogTagsTest do test "GET /blog/engineering/tag/:tag page shows empty list for nonexistent tag", %{ conn: conn } do - body = get(conn, "/blog/engineering/tag/nonexistent-tag") - assert html_response(body, 200) =~ ~s(tagged "nonexistent-tag") + conn_res = get(conn, "/blog/engineering/tag/nonexistent-tag") + assert html_response(conn_res, 200) =~ ~s(tagged "nonexistent-tag") end end @@ -45,30 +45,30 @@ defmodule FirehoseWeb.BlogTagsTest do end test "GET /blog/releases/tag/:tag page shows filtered posts", %{conn: conn} do - body = get(conn, "/blog/releases/tag/nonexistent-tag") - assert html_response(body, 200) =~ ~s(tagged "nonexistent-tag") + conn_res = get(conn, "/blog/releases/tag/nonexistent-tag") + assert html_response(conn_res, 200) =~ ~s(tagged "nonexistent-tag") end end describe "tag URL pattern" do test "tag URLs follow pattern /blog/:blog_id/tag/:tag for engineering blog", %{conn: conn} do # Test that the tag route exists and works correctly - conn = get(conn, "/blog/engineering/tag/elixir") - assert html_response(conn, 200) =~ ~s(tagged "elixir") + conn_res1 = get(conn, "/blog/engineering/tag/elixir") + assert html_response(conn_res1, 200) =~ ~s(tagged "elixir") - conn = get(conn, "/blog/engineering/tag/phoenix") - assert html_response(conn, 200) =~ ~s(tagged "phoenix") + conn_res2 = get(conn, "/blog/engineering/tag/phoenix") + assert html_response(conn_res2, 200) =~ ~s(tagged "phoenix") end test "tag URLs follow pattern /blog/:blog_id/tag/:tag for releases blog", %{conn: conn} do # Test that the tag route exists and works correctly - conn = get(conn, "/blog/releases/tag/release") - assert html_response(conn, 200) =~ ~s(tagged "release") + conn_res = get(conn, "/blog/releases/tag/release") + assert html_response(conn_res, 200) =~ ~s(tagged "release") end test "nonexistent tags return 200 with empty post list", %{conn: conn} do - conn = get(conn, "/blog/engineering/tag/nonexistent-tag") - assert html_response(conn, 200) + conn_res = get(conn, "/blog/engineering/tag/nonexistent-tag") + assert html_response(conn_res, 200) end end @@ -94,30 +94,30 @@ defmodule FirehoseWeb.BlogTagsTest do test "tags are rendered as clickable links on engineering blog index", %{ conn: conn } do - conn = get(conn, "/blog/engineering") - body = html_response(conn, 200) + conn_res1 = get(conn, "/blog/engineering") + body1 = html_response(conn_res1, 200) # Verify tag links exist with correct href pattern - assert body =~ ~r{href="/blog/engineering/tag/meta"} - assert body =~ ~r{href="/blog/engineering/tag/ai"} + assert body1 =~ ~r{href="/blog/engineering/tag/meta"} + assert body1 =~ ~r{href="/blog/engineering/tag/ai"} end test "tags are rendered as clickable links on releases blog index", %{ conn: conn } do - conn = get(conn, "/blog/releases") - body = html_response(conn, 200) + conn_res2 = get(conn, "/blog/releases") + body2 = html_response(conn_res2, 200) # Verify tag link exists - assert body =~ ~r{href="/blog/releases/tag/release"} + assert body2 =~ ~r{href="/blog/releases/tag/release"} end test "tag links have proper styling classes", %{conn: conn} do - conn = get(conn, "/blog/engineering") - body = html_response(conn, 200) + conn_res3 = get(conn, "/blog/engineering") + body3 = html_response(conn_res3, 200) # Verify blogex-tag-link class is present for tag links - assert body =~ ~r{class="[^"]*blogex-tag-link} + assert body3 =~ ~r{class="[^"]*blogex-tag-link} end end -end \ No newline at end of file +end diff --git a/autoresearch.jsonl b/autoresearch.jsonl new file mode 100644 index 0000000..1b11840 --- /dev/null +++ b/autoresearch.jsonl @@ -0,0 +1 @@ +{"type":"config","name":"Make build verification","metricName":"status","metricUnit":"","bestDirection":"lower"} diff --git a/nono.sh b/nono.sh index a52d41c..14a131e 100644 --- a/nono.sh +++ b/nono.sh @@ -5,6 +5,7 @@ nono run \ --allow /Users/willem/.local/share/mise \ --allow /Users/willem/.pi \ --read /Users/willem/.git \ + --read-file /Users/willem/.gitconfig \ --allow /Users/willem/Library/Caches/mise \ --allow-net \ - -- pi --verbose -p 'write a haiku' + -- pi --verbose From 04a736765d3d08e4397a876b495038797f73efae Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Tue, 24 Mar 2026 14:23:00 +0000 Subject: [PATCH 29/46] not running autoresearch at the moment --- autoresearch.jsonl | 1 - 1 file changed, 1 deletion(-) delete mode 100644 autoresearch.jsonl diff --git a/autoresearch.jsonl b/autoresearch.jsonl deleted file mode 100644 index 1b11840..0000000 --- a/autoresearch.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"type":"config","name":"Make build verification","metricName":"status","metricUnit":"","bestDirection":"lower"} From cf7df3111f4d7efc79cff4c478c4b632556d6cfc Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Tue, 24 Mar 2026 14:41:58 +0000 Subject: [PATCH 30/46] Linting rule only for dev and test Production build broke because our custom lint rule was compiled. The credo linter is not available and not necessary in production. Solution: create separate directory for dev tools. --- app/.credo.exs | 2 +- app/{lib => lib_dev}/firehose/checks/no_conn_shadowing.ex | 0 app/mix.exs | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) rename app/{lib => lib_dev}/firehose/checks/no_conn_shadowing.ex (100%) diff --git a/app/.credo.exs b/app/.credo.exs index 5638a4b..0a5f34a 100644 --- a/app/.credo.exs +++ b/app/.credo.exs @@ -41,7 +41,7 @@ # If you create your own checks, you must specify the source files for # them here, so they can be loaded by Credo before running the analysis. # - requires: ["lib/firehose/checks/"], + requires: ["lib_dev/firehose/checks/"], # # If you want to enforce a style guide and need a more traditional linting # experience, you can change `strict` to `true` below: diff --git a/app/lib/firehose/checks/no_conn_shadowing.ex b/app/lib_dev/firehose/checks/no_conn_shadowing.ex similarity index 100% rename from app/lib/firehose/checks/no_conn_shadowing.ex rename to app/lib_dev/firehose/checks/no_conn_shadowing.ex diff --git a/app/mix.exs b/app/mix.exs index bbb6131..f50949a 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -32,7 +32,8 @@ defmodule Firehose.MixProject do end # Specifies which paths to compile per environment. - defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(:test), do: ["lib", "lib_dev", "test/support"] + defp elixirc_paths(:dev), do: ["lib", "lib_dev"] defp elixirc_paths(_), do: ["lib"] # Specifies your project dependencies. From 17a0f2709c1c5a4f099907bf700fc9fd6574c687 Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Tue, 24 Mar 2026 14:56:54 +0000 Subject: [PATCH 31/46] Add current_tag attribute to post_index component --- blogex/lib/blogex/components.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blogex/lib/blogex/components.ex b/blogex/lib/blogex/components.ex index 2de29ce..5751c7a 100644 --- a/blogex/lib/blogex/components.ex +++ b/blogex/lib/blogex/components.ex @@ -23,9 +23,11 @@ defmodule Blogex.Components do * `:posts` - list of `%Blogex.Post{}` structs (required) * `:base_path` - base URL path for post links (required) + * `:current_tag` - currently selected tag for highlighting (optional) """ attr :posts, :list, required: true attr :base_path, :string, required: true + attr :current_tag, :string, default: nil def post_index(assigns) do ~H""" @@ -35,7 +37,7 @@ defmodule Blogex.Components do

{post.title}

- <.post_meta post={post} base_path={@base_path} /> + <.post_meta post={post} base_path={@base_path} current_tag={@current_tag} />

{post.description}

From 037e9f86ffab5cbe1c181788f0d633fa4e531ba5 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 20:24:33 +0000 Subject: [PATCH 32/46] Add post visibility and days_until_live helpers --- .beads/issues.jsonl | 12 ++++++ blogex/lib/blogex/post.ex | 17 ++++++++ blogex/test/blogex/post_visibility_test.exs | 45 +++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 .beads/issues.jsonl create mode 100644 blogex/test/blogex/post_visibility_test.exs diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..0b98412 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,12 @@ +{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.213785081Z","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:04.676875214Z","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.395327878Z"} +{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.478922912Z"} +{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:44.673871753Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:28.051938506Z","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.519698002Z"} +{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.294363414Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.441389296Z"} +{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:28.091149857Z","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:44.713739919Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.253169962Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/blogex/lib/blogex/post.ex b/blogex/lib/blogex/post.ex index a8b2775..3d6c4dd 100644 --- a/blogex/lib/blogex/post.ex +++ b/blogex/lib/blogex/post.ex @@ -44,6 +44,23 @@ defmodule Blogex.Post do published: boolean() } + @type visibility :: :draft | :scheduled | :live + + @doc "Returns the visibility of a post: :draft, :scheduled, or :live." + def visibility(%__MODULE__{published: false}), do: :draft + + def visibility(%__MODULE__{published: true, date: date}) do + if Date.after?(date, Date.utc_today()), do: :scheduled, else: :live + end + + @doc "Returns days until a scheduled post goes live, or nil." + def days_until_live(%__MODULE__{} = post) do + case visibility(post) do + :scheduled -> Date.diff(post.date, Date.utc_today()) + _ -> nil + end + end + @doc """ Build callback for NimblePublisher. diff --git a/blogex/test/blogex/post_visibility_test.exs b/blogex/test/blogex/post_visibility_test.exs new file mode 100644 index 0000000..4d98e03 --- /dev/null +++ b/blogex/test/blogex/post_visibility_test.exs @@ -0,0 +1,45 @@ +defmodule Blogex.Post.VisibilityTest do + use ExUnit.Case + + import Blogex.Test.PostBuilder + + describe "visibility/1" do + test "returns :draft when post is not published" do + post = build(published: false, date: ~D[2026-01-01]) + assert Blogex.Post.visibility(post) == :draft + end + + test "returns :scheduled when post is published with future date" do + post = build(published: true, date: ~D[2099-01-01]) + assert Blogex.Post.visibility(post) == :scheduled + end + + test "returns :live when post is published with past date" do + post = build(published: true, date: ~D[2020-01-01]) + assert Blogex.Post.visibility(post) == :live + end + + test "returns :live when post is published with today's date" do + post = build(published: true, date: Date.utc_today()) + assert Blogex.Post.visibility(post) == :live + end + end + + describe "days_until_live/1" do + test "returns positive integer for scheduled post" do + future = Date.add(Date.utc_today(), 10) + post = build(published: true, date: future) + assert Blogex.Post.days_until_live(post) == 10 + end + + test "returns nil for draft post" do + post = build(published: false, date: ~D[2099-01-01]) + assert Blogex.Post.days_until_live(post) == nil + end + + test "returns nil for live post" do + post = build(published: true, date: ~D[2020-01-01]) + assert Blogex.Post.days_until_live(post) == nil + end + end +end From 0577ceced0bf18748336fdb9af20c23ec57a9f40 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 20:30:27 +0000 Subject: [PATCH 33/46] Filter future-dated posts from public views and add unfiltered post access - all_posts/0 now excludes posts where date > today - all_tags/0 computed at runtime from filtered posts - posts_by_tag/1 and recent_posts/1 inherit date filtering - Add unfiltered_posts/0 to Blog macro and FakeBlog - Add all_posts_unfiltered/0 to Registry for dashboard use --- .beads/issues.jsonl | 2 +- blogex/lib/blogex.ex | 1 + blogex/lib/blogex/blog.ex | 16 ++++++++++--- blogex/lib/blogex/registry.ex | 7 ++++++ blogex/test/blogex/blog_test.exs | 35 ++++++++++++++++++++++++++++ blogex/test/blogex/registry_test.exs | 27 +++++++++++++++++++++ blogex/test/support/fake_blog.ex | 11 +++++++-- blogex/test/support/setup.ex | 6 +++++ 8 files changed, 99 insertions(+), 6 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0b98412..a91e2ce 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.213785081Z","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} {"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:04.676875214Z","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} {"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.395327878Z"} -{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.478922912Z"} +{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} {"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:44.673871753Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} {"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:28.051938506Z","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.519698002Z"} diff --git a/blogex/lib/blogex.ex b/blogex/lib/blogex.ex index c0cf2ce..4e37606 100644 --- a/blogex/lib/blogex.ex +++ b/blogex/lib/blogex.ex @@ -119,5 +119,6 @@ defmodule Blogex do defdelegate get_blog!(blog_id), to: Blogex.Registry defdelegate get_blog(blog_id), to: Blogex.Registry defdelegate all_posts, to: Blogex.Registry + defdelegate all_posts_unfiltered, to: Blogex.Registry defdelegate all_tags, to: Blogex.Registry end diff --git a/blogex/lib/blogex/blog.ex b/blogex/lib/blogex/blog.ex index 8ae265d..79cd6ac 100644 --- a/blogex/lib/blogex/blog.ex +++ b/blogex/lib/blogex/blog.ex @@ -73,12 +73,17 @@ defmodule Blogex.Blog do @doc "Returns the base URL path for this blog." def base_path, do: @blog_base_path + @doc "Returns all compiled posts regardless of published status or date." + def unfiltered_posts, do: @posts + @doc "Returns all visible posts, newest first. Drafts are included in dev/test." def all_posts do + today = Date.utc_today() + if Blogex.show_drafts?() do - @posts + Enum.filter(@posts, &(not Date.after?(&1.date, today))) else - Enum.filter(@posts, & &1.published) + Enum.filter(@posts, &(&1.published and not Date.after?(&1.date, today))) end end @@ -86,7 +91,12 @@ defmodule Blogex.Blog do def recent_posts(n \\ 5), do: Enum.take(all_posts(), n) @doc "Returns all unique tags across all published posts." - def all_tags, do: @tags + def all_tags do + all_posts() + |> Enum.flat_map(& &1.tags) + |> Enum.uniq() + |> Enum.sort() + end @doc "Returns all published posts matching the given tag." def posts_by_tag(tag) do diff --git a/blogex/lib/blogex/registry.ex b/blogex/lib/blogex/registry.ex index 9f02edc..5bde1f0 100644 --- a/blogex/lib/blogex/registry.ex +++ b/blogex/lib/blogex/registry.ex @@ -37,6 +37,13 @@ defmodule Blogex.Registry do |> Enum.sort_by(& &1.date, {:desc, Date}) end + @doc "Returns all posts from all blogs (unfiltered), sorted newest first." + def all_posts_unfiltered do + blogs() + |> Enum.flat_map(& &1.unfiltered_posts()) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + @doc "Returns all unique tags across all blogs." def all_tags do blogs() diff --git a/blogex/test/blogex/blog_test.exs b/blogex/test/blogex/blog_test.exs index fe89e9c..8faa9ce 100644 --- a/blogex/test/blogex/blog_test.exs +++ b/blogex/test/blogex/blog_test.exs @@ -15,6 +15,24 @@ defmodule Blogex.BlogTest do assert "draft-post" not in ids end + test "excludes future-dated posts", %{blog: blog} do + ids = blog.all_posts() |> Enum.map(& &1.id) + + refute "future-post" in ids + end + + test "includes today-dated published posts" do + {:ok, _} = FakeBlog.start([ + build(id: "today", date: Date.utc_today(), published: true), + build(id: "tomorrow", date: Date.add(Date.utc_today(), 1), published: true) + ]) + + ids = FakeBlog.all_posts() |> Enum.map(& &1.id) + + assert "today" in ids + refute "tomorrow" in ids + end + test "returns posts newest first", %{blog: blog} do dates = blog.all_posts() |> Enum.map(& &1.date) @@ -23,6 +41,13 @@ defmodule Blogex.BlogTest do end describe "recent_posts/1" do + test "excludes future-dated posts", %{blog: blog} do + posts = blog.recent_posts(100) + ids = Enum.map(posts, & &1.id) + + refute "future-post" in ids + end + test "returns at most n posts", %{blog: blog} do assert length(blog.recent_posts(2)) == 2 end @@ -35,6 +60,12 @@ defmodule Blogex.BlogTest do end describe "posts_by_tag/1" do + test "excludes future-dated posts", %{blog: blog} do + posts = blog.posts_by_tag("future-only") + + assert posts == [] + end + test "returns only posts with the given tag", %{blog: blog} do posts = blog.posts_by_tag("testing") @@ -59,6 +90,10 @@ defmodule Blogex.BlogTest do end describe "all_tags/0" do + test "excludes tags only on future-dated posts", %{blog: blog} do + refute "future-only" in blog.all_tags() + end + test "returns unique sorted tags from published posts", %{blog: blog} do tags = blog.all_tags() diff --git a/blogex/test/blogex/registry_test.exs b/blogex/test/blogex/registry_test.exs index 50a8ea2..795880b 100644 --- a/blogex/test/blogex/registry_test.exs +++ b/blogex/test/blogex/registry_test.exs @@ -8,12 +8,24 @@ defmodule Blogex.RegistryTest do def blog_id, do: :alpha def all_posts, do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)] def all_tags, do: ["elixir"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha), + Blogex.Test.PostBuilder.build(id: "a-draft", date: ~D[2026-03-05], blog: :alpha, published: false) + ] end defmodule BetaBlog do def blog_id, do: :beta def all_posts, do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)] def all_tags, do: ["devops"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta), + Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta) + ] end setup do @@ -77,6 +89,21 @@ defmodule Blogex.RegistryTest do end end + describe "all_posts_unfiltered/0" do + test "returns all posts including drafts and future-dated" do + ids = Registry.all_posts_unfiltered() |> Enum.map(& &1.id) + assert "a1" in ids + assert "a-draft" in ids + assert "b1" in ids + assert "b-future" in ids + end + + test "sorts by date descending" do + dates = Registry.all_posts_unfiltered() |> Enum.map(& &1.date) + assert dates == Enum.sort(dates, {:desc, Date}) + end + end + describe "blogs_map/0" do test "returns map keyed by blog_id" do map = Registry.blogs_map() diff --git a/blogex/test/support/fake_blog.ex b/blogex/test/support/fake_blog.ex index 918588e..104a8c3 100644 --- a/blogex/test/support/fake_blog.ex +++ b/blogex/test/support/fake_blog.ex @@ -51,9 +51,16 @@ defmodule Blogex.Test.FakeBlog do def description, do: get(:description) def base_path, do: get(:base_path) - def all_posts do + def unfiltered_posts do get(:posts) - |> Enum.filter(& &1.published) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def all_posts do + today = Date.utc_today() + + get(:posts) + |> Enum.filter(&(&1.published and not Date.after?(&1.date, today))) |> Enum.sort_by(& &1.date, {:desc, Date}) end diff --git a/blogex/test/support/setup.ex b/blogex/test/support/setup.ex index 83903dd..7dc15fa 100644 --- a/blogex/test/support/setup.ex +++ b/blogex/test/support/setup.ex @@ -44,6 +44,12 @@ defmodule Blogex.Test.Setup do date: ~D[2026-03-12], tags: ["elixir"], published: false + ), + build( + id: "future-post", + date: ~D[2099-01-01], + tags: ["future-only"], + published: true ) ] end From a380d0cb698248fe5c233b65c5eae0a08352eac9 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 20:31:13 +0000 Subject: [PATCH 34/46] Add phx.gen.auth authentication scaffolding - LiveView-based email/password auth via mix phx.gen.auth - Auth links removed from public navigation (direct URL access only) - Accounts context with User schema and token management --- app/AGENTS.md | 31 ++ app/config/config.exs | 13 + app/config/test.exs | 3 + app/lib/firehose/accounts.ex | 297 +++++++++++++ app/lib/firehose/accounts/scope.ex | 33 ++ app/lib/firehose/accounts/user.ex | 132 ++++++ app/lib/firehose/accounts/user_notifier.ex | 84 ++++ app/lib/firehose/accounts/user_token.ex | 156 +++++++ app/lib/firehose_web/components/layouts.ex | 1 + .../user_registration_controller.ex | 32 ++ .../controllers/user_registration_html.ex | 5 + .../user_registration_html/new.html.heex | 30 ++ .../controllers/user_session_controller.ex | 88 ++++ .../controllers/user_session_html.ex | 9 + .../user_session_html/confirm.html.heex | 57 +++ .../user_session_html/new.html.heex | 71 ++++ .../controllers/user_settings_controller.ex | 77 ++++ .../controllers/user_settings_html.ex | 5 + .../user_settings_html/edit.html.heex | 47 +++ app/lib/firehose_web/router.ex | 29 ++ app/lib/firehose_web/user_auth.ex | 219 ++++++++++ app/mix.exs | 1 + app/mix.lock | 2 + ...0260401201722_create_users_auth_tables.exs | 30 ++ app/test/firehose/accounts_test.exs | 397 ++++++++++++++++++ .../user_registration_controller_test.exs | 50 +++ .../user_session_controller_test.exs | 199 +++++++++ .../user_settings_controller_test.exs | 148 +++++++ app/test/firehose_web/user_auth_test.exs | 293 +++++++++++++ app/test/support/conn_case.ex | 41 ++ .../support/fixtures/accounts_fixtures.ex | 89 ++++ 31 files changed, 2669 insertions(+) create mode 100644 app/lib/firehose/accounts.ex create mode 100644 app/lib/firehose/accounts/scope.ex create mode 100644 app/lib/firehose/accounts/user.ex create mode 100644 app/lib/firehose/accounts/user_notifier.ex create mode 100644 app/lib/firehose/accounts/user_token.ex create mode 100644 app/lib/firehose_web/controllers/user_registration_controller.ex create mode 100644 app/lib/firehose_web/controllers/user_registration_html.ex create mode 100644 app/lib/firehose_web/controllers/user_registration_html/new.html.heex create mode 100644 app/lib/firehose_web/controllers/user_session_controller.ex create mode 100644 app/lib/firehose_web/controllers/user_session_html.ex create mode 100644 app/lib/firehose_web/controllers/user_session_html/confirm.html.heex create mode 100644 app/lib/firehose_web/controllers/user_session_html/new.html.heex create mode 100644 app/lib/firehose_web/controllers/user_settings_controller.ex create mode 100644 app/lib/firehose_web/controllers/user_settings_html.ex create mode 100644 app/lib/firehose_web/controllers/user_settings_html/edit.html.heex create mode 100644 app/lib/firehose_web/user_auth.ex create mode 100644 app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs create mode 100644 app/test/firehose/accounts_test.exs create mode 100644 app/test/firehose_web/controllers/user_registration_controller_test.exs create mode 100644 app/test/firehose_web/controllers/user_session_controller_test.exs create mode 100644 app/test/firehose_web/controllers/user_settings_controller_test.exs create mode 100644 app/test/firehose_web/user_auth_test.exs create mode 100644 app/test/support/fixtures/accounts_fixtures.ex diff --git a/app/AGENTS.md b/app/AGENTS.md index 6f52c21..de4d711 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -44,6 +44,37 @@ custom classes must fully style the input - Focus on **delightful details** like hover effects, loading states, and smooth page transitions + +## Authentication + +- **Always** handle authentication flow at the router level with proper redirects +- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs: + - A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline + - A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated + - In both cases, a `@current_scope` is assigned to the Plug connection + - A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users +- **Always let the user know in which router scopes and pipeline you are placing the route, AND SAY WHY** +- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign** +- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results +- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates +- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug as described below** + +### Routes that require authentication + +Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug: + + scope "/", AppWeb do + pipe_through [:browser, :require_authenticated_user] + + get "/", MyControllerThatRequiresAuth, :index + end + +### Routes that work with or without authentication + +Controllers automatically have the `current_scope` available if they use the `:browser` pipeline. + + + diff --git a/app/config/config.exs b/app/config/config.exs index 93010aa..a32c72b 100644 --- a/app/config/config.exs +++ b/app/config/config.exs @@ -7,6 +7,19 @@ # General application configuration import Config +config :firehose, :scopes, + user: [ + default: true, + module: Firehose.Accounts.Scope, + assign_key: :current_scope, + access_path: [:user, :id], + schema_key: :user_id, + schema_type: :id, + schema_table: :users, + test_data_fixture: Firehose.AccountsFixtures, + test_setup_helper: :register_and_log_in_user + ] + config :firehose, ecto_repos: [Firehose.Repo], generators: [timestamp_type: :utc_datetime] diff --git a/app/config/test.exs b/app/config/test.exs index ee30aa3..148c651 100644 --- a/app/config/test.exs +++ b/app/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/app/lib/firehose/accounts.ex b/app/lib/firehose/accounts.ex new file mode 100644 index 0000000..2850e5f --- /dev/null +++ b/app/lib/firehose/accounts.ex @@ -0,0 +1,297 @@ +defmodule Firehose.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Firehose.Repo + + alias Firehose.Accounts.{User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.email_changeset(attrs) + |> Repo.insert() + end + + ## Settings + + @doc """ + Checks whether the user is in sudo mode. + + The user is in sudo mode when the last authentication was done no further + than 20 minutes ago. The limit can be given as second argument in minutes. + """ + def sudo_mode?(user, minutes \\ -20) + + def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do + DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute)) + end + + def sudo_mode?(_user, _minutes), do: false + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + See `Firehose.Accounts.User.email_changeset/3` for a list of supported options. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}, opts \\ []) do + User.email_changeset(user, attrs, opts) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + Repo.transact(fn -> + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})), + {_count, _result} <- + Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do + {:ok, user} + else + _ -> {:error, :transaction_aborted} + end + end) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + See `Firehose.Accounts.User.password_changeset/3` for a list of supported options. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}, opts \\ []) do + User.password_changeset(user, attrs, opts) + end + + @doc """ + Updates the user password. + + Returns a tuple with the updated user, as well as a list of expired tokens. + + ## Examples + + iex> update_user_password(user, %{password: ...}) + {:ok, {%User{}, [...]}} + + iex> update_user_password(user, %{password: "too short"}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, attrs) do + user + |> User.password_changeset(attrs) + |> update_user_and_delete_all_tokens() + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + + If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Gets the user with the given magic link token. + """ + def get_user_by_magic_link_token(token) do + with {:ok, query} <- UserToken.verify_magic_link_token_query(token), + {user, _token} <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Logs the user in by magic link. + + There are three cases to consider: + + 1. The user has already confirmed their email. They are logged in + and the magic link is expired. + + 2. The user has not confirmed their email and no password is set. + In this case, the user gets confirmed, logged in, and all tokens - + including session ones - are expired. In theory, no other tokens + exist but we delete all of them for best security practices. + + 3. The user has not confirmed their email but a password is set. + This cannot happen in the default implementation but may be the + source of security pitfalls. See the "Mixing magic link and password registration" section of + `mix help phx.gen.auth`. + """ + def login_user_by_magic_link(token) do + {:ok, query} = UserToken.verify_magic_link_token_query(token) + + case Repo.one(query) do + # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password + {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) -> + raise """ + magic link log in is not allowed for unconfirmed users with a password set! + + This cannot happen with the default implementation, which indicates that you + might have adapted the code to a different use case. Please make sure to read the + "Mixing magic link and password registration" section of `mix help phx.gen.auth`. + """ + + {%User{confirmed_at: nil} = user, _token} -> + user + |> User.confirm_changeset() + |> update_user_and_delete_all_tokens() + + {user, token} -> + Repo.delete!(token) + {:ok, {user, []}} + + nil -> + {:error, :not_found} + end + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Delivers the magic link login instructions to the given user. + """ + def deliver_login_instructions(%User{} = user, magic_link_url_fun) + when is_function(magic_link_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "login") + Repo.insert!(user_token) + UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token)) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"])) + :ok + end + + ## Token helper + + defp update_user_and_delete_all_tokens(changeset) do + Repo.transact(fn -> + with {:ok, user} <- Repo.update(changeset) do + tokens_to_expire = Repo.all_by(UserToken, user_id: user.id) + + Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))) + + {:ok, {user, tokens_to_expire}} + end + end) + end +end diff --git a/app/lib/firehose/accounts/scope.ex b/app/lib/firehose/accounts/scope.ex new file mode 100644 index 0000000..7a560f0 --- /dev/null +++ b/app/lib/firehose/accounts/scope.ex @@ -0,0 +1,33 @@ +defmodule Firehose.Accounts.Scope do + @moduledoc """ + Defines the scope of the caller to be used throughout the app. + + The `Firehose.Accounts.Scope` allows public interfaces to receive + information about the caller, such as if the call is initiated from an + end-user, and if so, which user. Additionally, such a scope can carry fields + such as "super user" or other privileges for use as authorization, or to + ensure specific code paths can only be access for a given scope. + + It is useful for logging as well as for scoping pubsub subscriptions and + broadcasts when a caller subscribes to an interface or performs a particular + action. + + Feel free to extend the fields on this struct to fit the needs of + growing application requirements. + """ + + alias Firehose.Accounts.User + + defstruct user: nil + + @doc """ + Creates a scope for the given user. + + Returns nil if no user is given. + """ + def for_user(%User{} = user) do + %__MODULE__{user: user} + end + + def for_user(nil), do: nil +end diff --git a/app/lib/firehose/accounts/user.ex b/app/lib/firehose/accounts/user.ex new file mode 100644 index 0000000..458f208 --- /dev/null +++ b/app/lib/firehose/accounts/user.ex @@ -0,0 +1,132 @@ +defmodule Firehose.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :utc_datetime + field :authenticated_at, :utc_datetime, virtual: true + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registering or changing the email. + + It requires the email to change otherwise an error is added. + + ## Options + + * `:validate_unique` - Set to false if you don't want to validate the + uniqueness of the email, useful when displaying live validations. + Defaults to `true`. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + end + + defp validate_email(changeset, opts) do + changeset = + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, + message: "must have the @ sign and no spaces" + ) + |> validate_length(:email, max: 160) + + if Keyword.get(opts, :validate_unique, true) do + changeset + |> unsafe_validate_unique(:email, Firehose.Repo) + |> unique_constraint(:email) + |> validate_email_changed() + else + changeset + end + end + + defp validate_email_changed(changeset) do + if get_field(changeset, :email) && get_change(changeset, :email) == nil do + add_error(changeset, :email, "did not change") + else + changeset + end + end + + @doc """ + A user changeset for changing the password. + + It is important to validate the length of the password, as long passwords may + be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Firehose.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end +end diff --git a/app/lib/firehose/accounts/user_notifier.ex b/app/lib/firehose/accounts/user_notifier.ex new file mode 100644 index 0000000..c6d2c59 --- /dev/null +++ b/app/lib/firehose/accounts/user_notifier.ex @@ -0,0 +1,84 @@ +defmodule Firehose.Accounts.UserNotifier do + import Swoosh.Email + + alias Firehose.Mailer + alias Firehose.Accounts.User + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Firehose", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to log in with a magic link. + """ + def deliver_login_instructions(user, url) do + case user do + %User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url) + _ -> deliver_magic_link_instructions(user, url) + end + end + + defp deliver_magic_link_instructions(user, url) do + deliver(user.email, "Log in instructions", """ + + ============================== + + Hi #{user.email}, + + You can log into your account by visiting the URL below: + + #{url} + + If you didn't request this email, please ignore this. + + ============================== + """) + end + + defp deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end +end diff --git a/app/lib/firehose/accounts/user_token.ex b/app/lib/firehose/accounts/user_token.ex new file mode 100644 index 0000000..e95f5a5 --- /dev/null +++ b/app/lib/firehose/accounts/user_token.ex @@ -0,0 +1,156 @@ +defmodule Firehose.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias Firehose.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the magic link token expiry short, + # since someone with access to the email may take over the account. + @magic_link_validity_in_minutes 15 + @change_email_validity_in_days 7 + @session_validity_in_days 14 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + field :authenticated_at, :utc_datetime + belongs_to :user, Firehose.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix's default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + dt = user.authenticated_at || DateTime.utc_now(:second) + {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any, along with the token's creation time. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at} + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + If found, the query returns a tuple of the form `{user, token}`. + + The given token is valid if it matches its hashed counterpart in the + database. This function also checks whether the token has expired. The context + of a magic link token is always "login". + """ + def verify_magic_link_token_query(token) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, "login"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), + where: token.sent_to == user.email, + select: {user, token} + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user_token found by the token, if any. + + This is used to validate requests to change the user + email. + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + defp by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end +end diff --git a/app/lib/firehose_web/components/layouts.ex b/app/lib/firehose_web/components/layouts.ex index 727f358..6e33e2c 100644 --- a/app/lib/firehose_web/components/layouts.ex +++ b/app/lib/firehose_web/components/layouts.ex @@ -9,6 +9,7 @@ defmodule FirehoseWeb.Layouts do # The default root.html.heex file contains the HTML # skeleton of your application, namely HTML headers # and other static content. + embed_templates "layouts/*" @doc """ diff --git a/app/lib/firehose_web/controllers/user_registration_controller.ex b/app/lib/firehose_web/controllers/user_registration_controller.ex new file mode 100644 index 0000000..3c1ed89 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_registration_controller.ex @@ -0,0 +1,32 @@ +defmodule FirehoseWeb.UserRegistrationController do + use FirehoseWeb, :controller + + alias Firehose.Accounts + alias Firehose.Accounts.User + + def new(conn, _params) do + changeset = Accounts.change_user_email(%User{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"user" => user_params}) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + + conn + |> put_flash( + :info, + "An email was sent to #{user.email}, please access it to confirm your account." + ) + |> redirect(to: ~p"/users/log-in") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end +end diff --git a/app/lib/firehose_web/controllers/user_registration_html.ex b/app/lib/firehose_web/controllers/user_registration_html.ex new file mode 100644 index 0000000..4835923 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_registration_html.ex @@ -0,0 +1,5 @@ +defmodule FirehoseWeb.UserRegistrationHTML do + use FirehoseWeb, :html + + embed_templates "user_registration_html/*" +end diff --git a/app/lib/firehose_web/controllers/user_registration_html/new.html.heex b/app/lib/firehose_web/controllers/user_registration_html/new.html.heex new file mode 100644 index 0000000..d6444a1 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_registration_html/new.html.heex @@ -0,0 +1,30 @@ +
+
+ <.header> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + +
+ + <.form :let={f} for={@changeset} action={~p"/users/register"}> + <.input + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + + <.button phx-disable-with="Creating account..." class="btn btn-primary w-full"> + Create an account + + +
diff --git a/app/lib/firehose_web/controllers/user_session_controller.ex b/app/lib/firehose_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..acb48e3 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_controller.ex @@ -0,0 +1,88 @@ +defmodule FirehoseWeb.UserSessionController do + use FirehoseWeb, :controller + + alias Firehose.Accounts + alias FirehoseWeb.UserAuth + + def new(conn, _params) do + email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)]) + form = Phoenix.Component.to_form(%{"email" => email}, as: "user") + + render(conn, :new, form: form) + end + + # magic link login + def create(conn, %{"user" => %{"token" => token} = user_params} = params) do + info = + case params do + %{"_action" => "confirmed"} -> "User confirmed successfully." + _ -> "Welcome back!" + end + + case Accounts.login_user_by_magic_link(token) do + {:ok, {user, _expired_tokens}} -> + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + + {:error, :not_found} -> + conn + |> put_flash(:error, "The link is invalid or it has expired.") + |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user")) + end + end + + # email + password login + def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, "Welcome back!") + |> UserAuth.log_in_user(user, user_params) + else + form = Phoenix.Component.to_form(user_params, as: "user") + + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> render(:new, form: form) + end + end + + # magic link request + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions for logging in shortly." + + conn + |> put_flash(:info, info) + |> redirect(to: ~p"/users/log-in") + end + + def confirm(conn, %{"token" => token}) do + if user = Accounts.get_user_by_magic_link_token(token) do + form = Phoenix.Component.to_form(%{"token" => token}, as: "user") + + conn + |> assign(:user, user) + |> assign(:form, form) + |> render(:confirm) + else + conn + |> put_flash(:error, "Magic link is invalid or it has expired.") + |> redirect(to: ~p"/users/log-in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/app/lib/firehose_web/controllers/user_session_html.ex b/app/lib/firehose_web/controllers/user_session_html.ex new file mode 100644 index 0000000..9668513 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_html.ex @@ -0,0 +1,9 @@ +defmodule FirehoseWeb.UserSessionHTML do + use FirehoseWeb, :html + + embed_templates "user_session_html/*" + + defp local_mail_adapter? do + Application.get_env(:firehose, Firehose.Mailer)[:adapter] == Swoosh.Adapters.Local + end +end diff --git a/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex b/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex new file mode 100644 index 0000000..7597e79 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex @@ -0,0 +1,57 @@ +
+
+ <.header>Welcome {@user.email} +
+ + <.form + :if={!@user.confirmed_at} + for={@form} + id="confirmation_form" + action={~p"/users/log-in?_action=confirmed"} + phx-mounted={JS.focus_first()} + > + + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Confirming..." + class="btn btn-primary w-full" + > + Confirm and stay logged in + + <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> + Confirm and log in only this time + + + + <.form + :if={@user.confirmed_at} + for={@form} + id="login_form" + action={~p"/users/log-in"} + phx-mounted={JS.focus_first()} + > + + <%= if @current_scope do %> + <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full"> + Log in + + <% else %> + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Logging in..." + class="btn btn-primary w-full" + > + Keep me logged in on this device + + <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> + Log me in only this time + + <% end %> + + +

+ Tip: If you prefer passwords, you can enable them in the user settings. +

+
diff --git a/app/lib/firehose_web/controllers/user_session_html/new.html.heex b/app/lib/firehose_web/controllers/user_session_html/new.html.heex new file mode 100644 index 0000000..92ce5cd --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_html/new.html.heex @@ -0,0 +1,71 @@ +
+
+ <.header> +

Log in

+ <:subtitle> + <%= if @current_scope do %> + You need to reauthenticate to perform sensitive actions on your account. + <% else %> + Don't have an account? <.link + navigate={~p"/users/register"} + class="font-semibold text-brand hover:underline" + phx-no-format + >Sign up for an account now. + <% end %> + + +
+ +
+ <.icon name="hero-information-circle" class="size-6 shrink-0" /> +
+

You are running the local mail adapter.

+

+ To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. +

+
+
+ + <.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}> + <.input + readonly={!!@current_scope} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + <.button class="btn btn-primary w-full"> + Log in with email + + + +
or
+ + <.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}> + <.input + readonly={!!@current_scope} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + /> + <.input + field={f[:password]} + type="password" + label="Password" + autocomplete="current-password" + spellcheck="false" + /> + <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true"> + Log in and stay logged in + + <.button class="btn btn-primary btn-soft w-full mt-2"> + Log in only this time + + +
diff --git a/app/lib/firehose_web/controllers/user_settings_controller.ex b/app/lib/firehose_web/controllers/user_settings_controller.ex new file mode 100644 index 0000000..224f538 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_settings_controller.ex @@ -0,0 +1,77 @@ +defmodule FirehoseWeb.UserSettingsController do + use FirehoseWeb, :controller + + alias Firehose.Accounts + alias FirehoseWeb.UserAuth + + import FirehoseWeb.UserAuth, only: [require_sudo_mode: 2] + + plug :require_sudo_mode + plug :assign_email_and_password_changesets + + def edit(conn, _params) do + render(conn, :edit) + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"user" => user_params} = params + user = conn.assigns.current_scope.user + + case Accounts.change_user_email(user, user_params) do + %{valid?: true} = changeset -> + Accounts.deliver_user_update_email_instructions( + Ecto.Changeset.apply_action!(changeset, :insert), + user.email, + &url(~p"/users/settings/confirm-email/#{&1}") + ) + + conn + |> put_flash( + :info, + "A link to confirm your email change has been sent to the new address." + ) + |> redirect(to: ~p"/users/settings") + + changeset -> + render(conn, :edit, email_changeset: %{changeset | action: :insert}) + end + end + + def update(conn, %{"action" => "update_password"} = params) do + %{"user" => user_params} = params + user = conn.assigns.current_scope.user + + case Accounts.update_user_password(user, user_params) do + {:ok, {user, _}} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:user_return_to, ~p"/users/settings") + |> UserAuth.log_in_user(user) + + {:error, changeset} -> + render(conn, :edit, password_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_user_email(conn.assigns.current_scope.user, token) do + {:ok, _user} -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: ~p"/users/settings") + + {:error, _} -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: ~p"/users/settings") + end + end + + defp assign_email_and_password_changesets(conn, _opts) do + user = conn.assigns.current_scope.user + + conn + |> assign(:email_changeset, Accounts.change_user_email(user)) + |> assign(:password_changeset, Accounts.change_user_password(user)) + end +end diff --git a/app/lib/firehose_web/controllers/user_settings_html.ex b/app/lib/firehose_web/controllers/user_settings_html.ex new file mode 100644 index 0000000..e8522a5 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_settings_html.ex @@ -0,0 +1,5 @@ +defmodule FirehoseWeb.UserSettingsHTML do + use FirehoseWeb, :html + + embed_templates "user_settings_html/*" +end diff --git a/app/lib/firehose_web/controllers/user_settings_html/edit.html.heex b/app/lib/firehose_web/controllers/user_settings_html/edit.html.heex new file mode 100644 index 0000000..988ae4d --- /dev/null +++ b/app/lib/firehose_web/controllers/user_settings_html/edit.html.heex @@ -0,0 +1,47 @@ +
+ <.header> + Account Settings + <:subtitle>Manage your account email address and password settings + +
+ +<.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email"> + + + <.input + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + /> + + <.button variant="primary" phx-disable-with="Changing...">Change Email + + +
+ +<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password"> + + + <.input + field={f[:password]} + type="password" + label="New password" + autocomplete="new-password" + spellcheck="false" + required + /> + <.input + field={f[:password_confirmation]} + type="password" + label="Confirm new password" + autocomplete="new-password" + spellcheck="false" + required + /> + <.button variant="primary" phx-disable-with="Changing..."> + Save Password + + diff --git a/app/lib/firehose_web/router.ex b/app/lib/firehose_web/router.ex index 2a1e5ef..64c8d15 100644 --- a/app/lib/firehose_web/router.ex +++ b/app/lib/firehose_web/router.ex @@ -1,6 +1,8 @@ defmodule FirehoseWeb.Router do use FirehoseWeb, :router + import FirehoseWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -9,6 +11,7 @@ defmodule FirehoseWeb.Router do plug :put_layout, html: {FirehoseWeb.Layouts, :app} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_scope_for_user end pipeline :api do @@ -51,4 +54,30 @@ defmodule FirehoseWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", FirehoseWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + get "/users/register", UserRegistrationController, :new + post "/users/register", UserRegistrationController, :create + end + + scope "/", FirehoseWeb do + pipe_through [:browser, :require_authenticated_user] + + get "/users/settings", UserSettingsController, :edit + put "/users/settings", UserSettingsController, :update + get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email + end + + scope "/", FirehoseWeb do + pipe_through [:browser] + + get "/users/log-in", UserSessionController, :new + get "/users/log-in/:token", UserSessionController, :confirm + post "/users/log-in", UserSessionController, :create + delete "/users/log-out", UserSessionController, :delete + end end diff --git a/app/lib/firehose_web/user_auth.ex b/app/lib/firehose_web/user_auth.ex new file mode 100644 index 0000000..497f038 --- /dev/null +++ b/app/lib/firehose_web/user_auth.ex @@ -0,0 +1,219 @@ +defmodule FirehoseWeb.UserAuth do + use FirehoseWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias Firehose.Accounts + alias Firehose.Accounts.Scope + + # Make the remember me cookie valid for 14 days. This should match + # the session validity setting in UserToken. + @max_cookie_age_in_days 14 + @remember_me_cookie "_firehose_web_user_remember_me" + @remember_me_options [ + sign: true, + max_age: @max_cookie_age_in_days * 24 * 60 * 60, + same_site: "Lax" + ] + + # How old the session token should be before a new one is issued. When a request is made + # with a session token older than this value, then a new session token will be created + # and the session and remember-me cookies (if set) will be updated with the new token. + # Lowering this value will result in more tokens being created by active users. Increasing + # it will result in less time before a session token expires for a user to get issued a new + # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable + # the reissuing of tokens completely. + @session_reissue_age_in_days 7 + + @doc """ + Logs the user in. + + Redirects to the session's `:user_return_to` path + or falls back to the `signed_in_path/1`. + """ + def log_in_user(conn, user, params \\ %{}) do + user_return_to = get_session(conn, :user_return_to) + + conn + |> create_or_extend_session(user, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + FirehoseWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session(nil) + |> delete_resp_cookie(@remember_me_cookie, @remember_me_options) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session and remember me token. + + Will reissue the session token if it is older than the configured age. + """ + def fetch_current_scope_for_user(conn, _opts) do + with {token, conn} <- ensure_user_token(conn), + {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do + conn + |> assign(:current_scope, Scope.for_user(user)) + |> maybe_reissue_user_session_token(user, token_inserted_at) + else + nil -> assign(conn, :current_scope, Scope.for_user(nil)) + end + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)} + else + nil + end + end + end + + # Reissue the session token if it is older than the configured reissue age. + defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do + token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day) + + if token_age >= @session_reissue_age_in_days do + create_or_extend_session(conn, user, %{}) + else + conn + end + end + + # This function is the one responsible for creating session tokens + # and storing them safely in the session and cookies. It may be called + # either when logging in, during sudo mode, or to renew a session which + # will soon expire. + # + # When the session is created, rather than extended, the renew_session + # function will clear the session to avoid fixation attacks. See the + # renew_session function to customize this behaviour. + defp create_or_extend_session(conn, user, params) do + token = Accounts.generate_user_session_token(user) + remember_me = get_session(conn, :user_remember_me) + + conn + |> renew_session(user) + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params, remember_me) + end + + # Do not renew session if the user is already logged in + # to prevent CSRF errors or data being lost in tabs that are still open + defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn, _user) do + # delete_csrf_token() + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn, _user) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _), + do: write_remember_me_cookie(conn, token) + + defp maybe_write_remember_me_cookie(conn, token, _params, true), + do: write_remember_me_cookie(conn, token) + + defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn + + defp write_remember_me_cookie(conn, token) do + conn + |> put_session(:user_remember_me, true) + |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) + end + + defp put_token_in_session(conn, token) do + put_session(conn, :user_token, token) + end + + @doc """ + Plug for routes that require sudo mode. + """ + def require_sudo_mode(conn, _opts) do + if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do + conn + else + conn + |> put_flash(:error, "You must re-authenticate to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log-in") + |> halt() + end + end + + @doc """ + Plug for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns.current_scope do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + defp signed_in_path(_conn), do: ~p"/" + + @doc """ + Plug for routes that require the user to be authenticated. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns.current_scope && conn.assigns.current_scope.user do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log-in") + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn +end diff --git a/app/mix.exs b/app/mix.exs index f50949a..f6ad7a3 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -41,6 +41,7 @@ defmodule Firehose.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.8.1"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.13"}, diff --git a/app/mix.lock b/app/mix.lock index efbc8af..b58e1bc 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -1,7 +1,9 @@ %{ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, diff --git a/app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs b/app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs new file mode 100644 index 0000000..cd9c39f --- /dev/null +++ b/app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs @@ -0,0 +1,30 @@ +defmodule Firehose.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users) do + add :email, :citext, null: false + add :hashed_password, :string + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + add :authenticated_at, :utc_datetime + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/app/test/firehose/accounts_test.exs b/app/test/firehose/accounts_test.exs new file mode 100644 index 0000000..abb569e --- /dev/null +++ b/app/test/firehose/accounts_test.exs @@ -0,0 +1,397 @@ +defmodule Firehose.AccountsTest do + use Firehose.DataCase + + alias Firehose.Accounts + + import Firehose.AccountsFixtures + alias Firehose.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() |> set_password() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() |> set_password() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{email: ["can't be blank"]} = errors_on(changeset) + end + + test "validates email when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum values for email for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the uppercased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users without password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_nil(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "sudo_mode?/2" do + test "validates the authenticated_at time" do + now = DateTime.utc_now() + + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()}) + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)}) + refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)}) + + # minute override + refute Accounts.sudo_mode?( + %User{authenticated_at: DateTime.add(now, -11, :minute)}, + -10 + ) + + # not authenticated + refute Accounts.sudo_mode?(%User{}) + end + end + + describe "change_user_email/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = unconfirmed_user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token) + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + + assert Accounts.update_user_email(user, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password( + %User{}, + %{ + "password" => "new valid password" + }, + hash_password: false + ) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, {user, expired_tokens}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + assert expired_tokens == [] + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, {_, _}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + assert user_token.authenticated_at != nil + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + + test "duplicates the authenticated_at of given user in new token", %{user: user} do + user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)} + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.authenticated_at == user.authenticated_at + assert DateTime.compare(user_token.inserted_at, user.authenticated_at) == :gt + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + assert session_user.authenticated_at != nil + assert token_inserted_at != nil + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + dt = ~N[2020-01-01 00:00:00] + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "get_user_by_magic_link_token/1" do + setup do + user = user_fixture() + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + %{user: user, token: encoded_token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_magic_link_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_magic_link_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_magic_link_token(token) + end + end + + describe "login_user_by_magic_link/1" do + test "confirms user and expires tokens" do + user = unconfirmed_user_fixture() + refute user.confirmed_at + {encoded_token, hashed_token} = generate_user_magic_link_token(user) + + assert {:ok, {user, [%{token: ^hashed_token}]}} = + Accounts.login_user_by_magic_link(encoded_token) + + assert user.confirmed_at + end + + test "returns user and (deleted) token for confirmed user" do + user = user_fixture() + assert user.confirmed_at + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token) + # one time use only + assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token) + end + + test "raises when unconfirmed user has password set" do + user = unconfirmed_user_fixture() + {1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"]) + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + + assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn -> + Accounts.login_user_by_magic_link(encoded_token) + end + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_login_instructions/2" do + setup do + %{user: unconfirmed_user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "login" + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/app/test/firehose_web/controllers/user_registration_controller_test.exs b/app/test/firehose_web/controllers/user_registration_controller_test.exs new file mode 100644 index 0000000..edd2601 --- /dev/null +++ b/app/test/firehose_web/controllers/user_registration_controller_test.exs @@ -0,0 +1,50 @@ +defmodule FirehoseWeb.UserRegistrationControllerTest do + use FirehoseWeb.ConnCase, async: true + + import Firehose.AccountsFixtures + + describe "GET /users/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, ~p"/users/register") + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ ~p"/users/log-in" + assert response =~ ~p"/users/register" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_user(user_fixture()) |> get(~p"/users/register") + + assert redirected_to(conn) == ~p"/" + end + end + + describe "POST /users/register" do + @tag :capture_log + test "creates account but does not log in", %{conn: conn} do + email = unique_user_email() + + conn = + post(conn, ~p"/users/register", %{ + "user" => valid_user_attributes(email: email) + }) + + refute get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/users/log-in" + + assert conn.assigns.flash["info"] =~ + ~r/An email was sent to .*, please access it to confirm your account/ + end + + test "render errors for invalid data", %{conn: conn} do + conn = + post(conn, ~p"/users/register", %{ + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ "must have the @ sign and no spaces" + end + end +end diff --git a/app/test/firehose_web/controllers/user_session_controller_test.exs b/app/test/firehose_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..4a92555 --- /dev/null +++ b/app/test/firehose_web/controllers/user_session_controller_test.exs @@ -0,0 +1,199 @@ +defmodule FirehoseWeb.UserSessionControllerTest do + use FirehoseWeb.ConnCase, async: true + + import Firehose.AccountsFixtures + alias Firehose.Accounts + + setup do + %{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture()} + end + + describe "GET /users/log-in" do + test "renders login page", %{conn: conn} do + conn = get(conn, ~p"/users/log-in") + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ ~p"/users/register" + assert response =~ "Log in with email" + end + + test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do + html = + conn + |> log_in_user(user) + |> get(~p"/users/log-in") + |> html_response(200) + + assert html =~ "You need to reauthenticate" + refute html =~ "Register" + assert html =~ "Log in with email" + + assert html =~ + ~s( + Accounts.deliver_login_instructions(user, url) + end) + + conn = get(conn, ~p"/users/log-in/#{token}") + assert html_response(conn, 200) =~ "Confirm and stay logged in" + end + + test "renders login page for confirmed user", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + conn = get(conn, ~p"/users/log-in/#{token}") + html = html_response(conn, 200) + refute html =~ "Confirm my account" + assert html =~ "Keep me logged in on this device" + end + + test "raises error for invalid token", %{conn: conn} do + conn = get(conn, ~p"/users/log-in/invalid-token") + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Magic link is invalid or it has expired." + end + end + + describe "POST /users/log-in - email and password" do + test "logs the user in", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_firehose_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + user = set_password(user) + + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "emits error message with invalid credentials", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log-in?mode=password", %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ "Invalid email or password" + end + end + + describe "POST /users/log-in - magic link" do + test "sends magic link email when user exists", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Firehose.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login" + end + + test "logs the user in", %{conn: conn, user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + end + + test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + refute user.confirmed_at + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token}, + "_action" => "confirmed" + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully." + + assert Accounts.get_user!(user.id).confirmed_at + end + + test "emits error message when magic link is invalid", %{conn: conn} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => "invalid"} + }) + + assert html_response(conn, 200) =~ "The link is invalid or it has expired." + end + end + + describe "DELETE /users/log-out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/app/test/firehose_web/controllers/user_settings_controller_test.exs b/app/test/firehose_web/controllers/user_settings_controller_test.exs new file mode 100644 index 0000000..d4476f8 --- /dev/null +++ b/app/test/firehose_web/controllers/user_settings_controller_test.exs @@ -0,0 +1,148 @@ +defmodule FirehoseWeb.UserSettingsControllerTest do + use FirehoseWeb.ConnCase, async: true + + alias Firehose.Accounts + import Firehose.AccountsFixtures + + setup :register_and_log_in_user + + describe "GET /users/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, ~p"/users/settings") + response = html_response(conn, 200) + assert response =~ "Settings" + end + + test "redirects if user is not logged in" do + conn = build_conn() + conn = get(conn, ~p"/users/settings") + assert redirected_to(conn) == ~p"/users/log-in" + end + + @tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute) + test "redirects if user is not in sudo mode", %{conn: conn} do + conn = get(conn, ~p"/users/settings") + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." + end + end + + describe "PUT /users/settings (change password form)" do + test "updates the user password and resets tokens", %{conn: conn, user: user} do + new_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "Settings" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + + assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) + end + end + + describe "PUT /users/settings (change email form)" do + @tag :capture_log + test "updates the user email", %{conn: conn, user: user} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "user" => %{"email" => unique_user_email()} + }) + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "A link to confirm your email" + + assert Accounts.get_user_by_email(user.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "Settings" + assert response =~ "must have the @ sign and no spaces" + end + end + + describe "GET /users/settings/confirm-email/:token" do + setup %{user: user} do + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{token: token, email: email} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Email changed successfully" + + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + conn = get(conn, ~p"/users/settings/confirm-email/oops") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/log-in" + end + end +end diff --git a/app/test/firehose_web/user_auth_test.exs b/app/test/firehose_web/user_auth_test.exs new file mode 100644 index 0000000..ba4d685 --- /dev/null +++ b/app/test/firehose_web/user_auth_test.exs @@ -0,0 +1,293 @@ +defmodule FirehoseWeb.UserAuthTest do + use FirehoseWeb.ConnCase, async: true + + alias Firehose.Accounts + alias Firehose.Accounts.Scope + alias FirehoseWeb.UserAuth + + import Firehose.AccountsFixtures + + @remember_me_cookie "_firehose_web_user_remember_me" + @remember_me_cookie_max_age 60 * 60 * 24 * 14 + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, FirehoseWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: %{user_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "keeps session when re-authenticating", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + assert get_session(conn, :to_be_removed) + end + + test "clears session when user does not match when re-authenticating", %{ + conn: conn, + user: user + } do + other_user = user_fixture() + + conn = + conn + |> assign(:current_scope, Scope.for_user(other_user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + end + + test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + conn = + conn + |> recycle() + |> Map.replace!(:secret_key_base, FirehoseWeb.Endpoint.config(:secret_key_base)) + |> fetch_cookies() + |> init_test_session(%{user_remember_me: true}) + + # the conn is already logged in and has the remember_me cookie set, + # now we log in again and even without explicitly setting remember_me, + # the cookie should be set again + conn = conn |> UserAuth.log_in_user(user, %{}) + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + assert get_session(conn, :user_remember_me) == true + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_scope_for_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + assert get_session(conn, :user_remember_me) + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_scope_for_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_scope + end + + test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + offset_user_token(token, -10, :day) + {user, _} = Accounts.get_user_by_session_token(token) + + conn = + conn + |> put_session(:user_token, token) + |> put_session(:user_remember_me, true) + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert new_token = get_session(conn, :user_token) + assert new_token != token + assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert new_signed_token != signed_token + assert max_age == @remember_me_cookie_max_age + end + end + + describe "require_sudo_mode/2" do + test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + refute conn.halted + refute conn.status + end + + test "redirects when authentication is too old", %{conn: conn, user: user} do + eleven_minutes_ago = DateTime.utc_now(:second) |> DateTime.add(-11, :minute) + user = %{user | authenticated_at: eleven_minutes_ago} + user_token = Accounts.generate_user_session_token(user) + {user, token_inserted_at} = Accounts.get_user_by_session_token(user_token) + assert DateTime.compare(token_inserted_at, user.authenticated_at) == :gt + + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." + end + end + + describe "redirect_if_user_is_authenticated/2" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.redirect_if_user_is_authenticated([]) + + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_authenticated_user([]) + + refute conn.halted + refute conn.status + end + end +end diff --git a/app/test/support/conn_case.ex b/app/test/support/conn_case.ex index 0d4b55a..4616a64 100644 --- a/app/test/support/conn_case.ex +++ b/app/test/support/conn_case.ex @@ -35,4 +35,45 @@ defmodule FirehoseWeb.ConnCase do Firehose.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn} = context) do + user = Firehose.AccountsFixtures.user_fixture() + scope = Firehose.Accounts.Scope.for_user(user) + + opts = + context + |> Map.take([:token_authenticated_at]) + |> Enum.into([]) + + %{conn: log_in_user(conn, user, opts), user: user, scope: scope} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user, opts \\ []) do + token = Firehose.Accounts.generate_user_session_token(user) + + maybe_set_token_authenticated_at(token, opts[:token_authenticated_at]) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end + + defp maybe_set_token_authenticated_at(_token, nil), do: nil + + defp maybe_set_token_authenticated_at(token, authenticated_at) do + Firehose.AccountsFixtures.override_token_authenticated_at(token, authenticated_at) + end end diff --git a/app/test/support/fixtures/accounts_fixtures.ex b/app/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..c722399 --- /dev/null +++ b/app/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,89 @@ +defmodule Firehose.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Firehose.Accounts` context. + """ + + import Ecto.Query + + alias Firehose.Accounts + alias Firehose.Accounts.Scope + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email() + }) + end + + def unconfirmed_user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Accounts.register_user() + + user + end + + def user_fixture(attrs \\ %{}) do + user = unconfirmed_user_fixture(attrs) + + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, {user, _expired_tokens}} = + Accounts.login_user_by_magic_link(token) + + user + end + + def user_scope_fixture do + user = user_fixture() + user_scope_fixture(user) + end + + def user_scope_fixture(user) do + Scope.for_user(user) + end + + def set_password(user) do + {:ok, {user, _expired_tokens}} = + Accounts.update_user_password(user, %{password: valid_user_password()}) + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end + + def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do + Firehose.Repo.update_all( + from(t in Accounts.UserToken, + where: t.token == ^token + ), + set: [authenticated_at: authenticated_at] + ) + end + + def generate_user_magic_link_token(user) do + {encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login") + Firehose.Repo.insert!(user_token) + {encoded_token, user_token.token} + end + + def offset_user_token(token, amount_to_add, unit) do + dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit) + + Firehose.Repo.update_all( + from(ut in Accounts.UserToken, where: ut.token == ^token), + set: [inserted_at: dt, authenticated_at: dt] + ) + end +end From 370275f7b5349da74ab5629222086fce87d3d481 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 20:37:27 +0000 Subject: [PATCH 35/46] Verify feeds exclude future-dated posts --- .beads/issues.jsonl | 18 +++++++++--------- blogex/test/blogex/feed_test.exs | 10 ++++++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a91e2ce..aec5ab7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,12 +1,12 @@ -{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.213785081Z","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:04.676875214Z","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.395327878Z"} +{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.516091483Z","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.476647693Z","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"} {"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} -{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:44.673871753Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:28.051938506Z","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.519698002Z"} +{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.635424214Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.555637642Z","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"} {"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.294363414Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:15:39.441389296Z"} -{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:28.091149857Z","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:44.713739919Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} +{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.595001752Z","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.675871251Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} {"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.253169962Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/blogex/test/blogex/feed_test.exs b/blogex/test/blogex/feed_test.exs index 7409c22..b33b89a 100644 --- a/blogex/test/blogex/feed_test.exs +++ b/blogex/test/blogex/feed_test.exs @@ -65,6 +65,11 @@ defmodule Blogex.FeedTest do refute xml =~ "draft-post" end + test "excludes future-dated published posts", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + refute xml =~ "future-post" + end + test "includes self-referencing atom:link", %{blog: blog} do xml = Feed.rss(blog, @base_url) @@ -95,6 +100,11 @@ defmodule Blogex.FeedTest do entry_count = xml |> String.split("") |> length() |> Kernel.-(1) assert entry_count == 2 end + + test "excludes future-dated published posts", %{blog: blog} do + xml = Feed.atom(blog, @base_url) + refute xml =~ "future-post" + end end describe "XML escaping" do From 20f12847d66e8050ac2287861d38be4238f83243 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 20:39:19 +0000 Subject: [PATCH 36/46] Allow direct access to draft and scheduled posts by slug --- .beads/issues.jsonl | 2 +- blogex/lib/blogex/blog.ex | 4 ++-- blogex/test/blogex/blog_test.exs | 20 ++++++++++++++++---- blogex/test/blogex/router_test.exs | 4 ++-- blogex/test/support/fake_blog.ex | 4 ++-- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index aec5ab7..70fdefa 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.516091483Z","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:38:37.480901856Z","closed_at":"2026-04-01T20:38:37.480901856Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} {"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.476647693Z","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} {"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"} {"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} diff --git a/blogex/lib/blogex/blog.ex b/blogex/lib/blogex/blog.ex index 79cd6ac..01669b0 100644 --- a/blogex/lib/blogex/blog.ex +++ b/blogex/lib/blogex/blog.ex @@ -105,13 +105,13 @@ defmodule Blogex.Blog do @doc "Returns a single post by slug/id, or raises." def get_post!(id) do - Enum.find(all_posts(), &(&1.id == id)) || + Enum.find(unfiltered_posts(), &(&1.id == id)) || raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}" end @doc "Returns a single post by slug/id, or nil." def get_post(id) do - Enum.find(all_posts(), &(&1.id == id)) + Enum.find(unfiltered_posts(), &(&1.id == id)) end @doc "Returns paginated posts. Page is 1-indexed." diff --git a/blogex/test/blogex/blog_test.exs b/blogex/test/blogex/blog_test.exs index 8faa9ce..6b5afd2 100644 --- a/blogex/test/blogex/blog_test.exs +++ b/blogex/test/blogex/blog_test.exs @@ -125,10 +125,14 @@ defmodule Blogex.BlogTest do end end - test "raises for draft post id", %{blog: blog} do - assert_raise Blogex.NotFoundError, fn -> - blog.get_post!("draft-post") - end + test "returns a future-dated published post by slug", %{blog: blog} do + post = blog.get_post!("future-post") + assert post.id == "future-post" + end + + test "returns a draft post by slug", %{blog: blog} do + post = blog.get_post!("draft-post") + assert post.id == "draft-post" end end @@ -136,6 +140,14 @@ defmodule Blogex.BlogTest do test "returns nil for unknown id", %{blog: blog} do assert blog.get_post("nope") == nil end + + test "returns a future-dated post", %{blog: blog} do + assert %{id: "future-post"} = blog.get_post("future-post") + end + + test "returns a draft post", %{blog: blog} do + assert %{id: "draft-post"} = blog.get_post("draft-post") + end end describe "paginate/2" do diff --git a/blogex/test/blogex/router_test.exs b/blogex/test/blogex/router_test.exs index 221eaa2..0e5f985 100644 --- a/blogex/test/blogex/router_test.exs +++ b/blogex/test/blogex/router_test.exs @@ -70,10 +70,10 @@ defmodule Blogex.RouterTest do assert conn.status == 404 end - test "returns 404 for draft post" do + test "returns 200 for draft post accessed by slug" do conn = call(:get, "/draft") - assert conn.status == 404 + assert conn.status == 200 end end diff --git a/blogex/test/support/fake_blog.ex b/blogex/test/support/fake_blog.ex index 104a8c3..a62d96b 100644 --- a/blogex/test/support/fake_blog.ex +++ b/blogex/test/support/fake_blog.ex @@ -78,12 +78,12 @@ defmodule Blogex.Test.FakeBlog do end def get_post!(id) do - Enum.find(all_posts(), &(&1.id == id)) || + Enum.find(unfiltered_posts(), &(&1.id == id)) || raise Blogex.NotFoundError, "post #{inspect(id)} not found" end def get_post(id) do - Enum.find(all_posts(), &(&1.id == id)) + Enum.find(unfiltered_posts(), &(&1.id == id)) end def paginate(page \\ 1, per_page \\ 10) do From df20c478f41d8db1491fffe3f5937f28d85fd29d Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:36:58 +0000 Subject: [PATCH 37/46] Seed demo user in dev environment --- .beads/issues.jsonl | 6 +++--- app/priv/repo/seeds.exs | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 70fdefa..62b0ee8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,12 +1,12 @@ {"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:38:37.480901856Z","closed_at":"2026-04-01T20:38:37.480901856Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.476647693Z","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:39:26.605057721Z","closed_at":"2026-04-01T20:39:26.605057721Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} {"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"} {"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} {"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.635424214Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} {"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.555637642Z","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"} -{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.294363414Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} {"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} {"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.595001752Z","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.675871251Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"open","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:07:16.253169962Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.918341344Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/app/priv/repo/seeds.exs b/app/priv/repo/seeds.exs index 369044f..36dbe87 100644 --- a/app/priv/repo/seeds.exs +++ b/app/priv/repo/seeds.exs @@ -9,3 +9,13 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +if Mix.env() == :dev do + alias Firehose.Accounts + + # Create demo user if not already present + unless Accounts.get_user_by_email("demo@example.com") do + {:ok, user} = Accounts.register_user(%{email: "demo@example.com"}) + {:ok, {_user, _tokens}} = Accounts.update_user_password(user, %{password: "password123!"}) + end +end From 86f7ffbe94633a1544fe23107b86160a077ce440 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:39:15 +0000 Subject: [PATCH 38/46] Gate registration to ALLOWED_REGISTRATION_EMAIL --- .beads/issues.jsonl | 2 +- app/config/runtime.exs | 2 + app/config/test.exs | 2 + .../user_registration_controller.ex | 42 ++++++++++++------- .../user_registration_controller_test.exs | 30 +++++++++++++ 5 files changed, 62 insertions(+), 16 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 62b0ee8..b372ebf 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -7,6 +7,6 @@ {"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"} {"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} {"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} -{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.595001752Z","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.675871251Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} {"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.918341344Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/app/config/runtime.exs b/app/config/runtime.exs index f0f4e40..162e9cb 100644 --- a/app/config/runtime.exs +++ b/app/config/runtime.exs @@ -20,6 +20,8 @@ if System.get_env("PHX_SERVER") do config :firehose, FirehoseWeb.Endpoint, server: true end +config :firehose, :allowed_registration_email, System.get_env("ALLOWED_REGISTRATION_EMAIL") + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || diff --git a/app/config/test.exs b/app/config/test.exs index 148c651..524ff6b 100644 --- a/app/config/test.exs +++ b/app/config/test.exs @@ -38,3 +38,5 @@ config :phoenix, :plug_init_mode, :runtime # Enable helpful, but potentially expensive runtime checks config :phoenix_live_view, enable_expensive_runtime_checks: true + +config :firehose, :allowed_registration_email, nil diff --git a/app/lib/firehose_web/controllers/user_registration_controller.ex b/app/lib/firehose_web/controllers/user_registration_controller.ex index 3c1ed89..3c4245e 100644 --- a/app/lib/firehose_web/controllers/user_registration_controller.ex +++ b/app/lib/firehose_web/controllers/user_registration_controller.ex @@ -10,23 +10,35 @@ defmodule FirehoseWeb.UserRegistrationController do end def create(conn, %{"user" => user_params}) do - case Accounts.register_user(user_params) do - {:ok, user} -> - {:ok, _} = - Accounts.deliver_login_instructions( - user, - &url(~p"/users/log-in/#{&1}") + allowed_email = Application.get_env(:firehose, :allowed_registration_email) + + if allowed_email == nil or user_params["email"] != allowed_email do + changeset = + %User{} + |> Accounts.change_user_email(user_params) + |> Ecto.Changeset.add_error(:email, "registration is invite only.") + |> Map.put(:action, :validate) + + render(conn, :new, changeset: changeset) + else + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + + conn + |> put_flash( + :info, + "An email was sent to #{user.email}, please access it to confirm your account." ) + |> redirect(to: ~p"/users/log-in") - conn - |> put_flash( - :info, - "An email was sent to #{user.email}, please access it to confirm your account." - ) - |> redirect(to: ~p"/users/log-in") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :new, changeset: changeset) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end end end end diff --git a/app/test/firehose_web/controllers/user_registration_controller_test.exs b/app/test/firehose_web/controllers/user_registration_controller_test.exs index edd2601..b26a34c 100644 --- a/app/test/firehose_web/controllers/user_registration_controller_test.exs +++ b/app/test/firehose_web/controllers/user_registration_controller_test.exs @@ -23,6 +23,8 @@ defmodule FirehoseWeb.UserRegistrationControllerTest do @tag :capture_log test "creates account but does not log in", %{conn: conn} do email = unique_user_email() + Application.put_env(:firehose, :allowed_registration_email, email) + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) conn = post(conn, ~p"/users/register", %{ @@ -37,6 +39,9 @@ defmodule FirehoseWeb.UserRegistrationControllerTest do end test "render errors for invalid data", %{conn: conn} do + Application.put_env(:firehose, :allowed_registration_email, "with spaces") + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) + conn = post(conn, ~p"/users/register", %{ "user" => %{"email" => "with spaces"} @@ -47,4 +52,29 @@ defmodule FirehoseWeb.UserRegistrationControllerTest do assert response =~ "must have the @ sign and no spaces" end end + + describe "POST /users/register with email gating" do + test "succeeds when email matches ALLOWED_REGISTRATION_EMAIL", %{conn: conn} do + Application.put_env(:firehose, :allowed_registration_email, "allowed@example.com") + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) + + conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "allowed@example.com"}}) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "email was sent" + end + + test "fails with invite-only message when email doesn't match", %{conn: conn} do + Application.put_env(:firehose, :allowed_registration_email, "allowed@example.com") + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) + + conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "other@example.com"}}) + assert html_response(conn, 200) =~ "registration is invite only" + end + + test "fails with invite-only message when env var is unset", %{conn: conn} do + Application.delete_env(:firehose, :allowed_registration_email) + + conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "anyone@example.com"}}) + assert html_response(conn, 200) =~ "registration is invite only" + end + end end From 5395b2de80ab97e2416bf12a5ba00d9eea694be6 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:40:17 +0000 Subject: [PATCH 39/46] Show draft/scheduled status banners for authenticated users --- .beads/issues.jsonl | 2 +- .../controllers/blog_controller.ex | 5 +- .../controllers/blog_html/show.html.heex | 19 ++++++ .../2099/01-01-future-test-post.md | 8 +++ .../controllers/blog_controller_test.exs | 58 +++++++++++++++++++ 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 app/priv/blog/engineering/2099/01-01-future-test-post.md create mode 100644 app/test/firehose_web/controllers/blog_controller_test.exs diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b372ebf..39f5146 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -3,7 +3,7 @@ {"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"} {"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} {"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.635424214Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.555637642Z","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:39:21.420987916Z","closed_at":"2026-04-01T21:39:21.420987916Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"} {"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} {"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} diff --git a/app/lib/firehose_web/controllers/blog_controller.ex b/app/lib/firehose_web/controllers/blog_controller.ex index 8e243e1..aa07a62 100644 --- a/app/lib/firehose_web/controllers/blog_controller.ex +++ b/app/lib/firehose_web/controllers/blog_controller.ex @@ -22,11 +22,14 @@ defmodule FirehoseWeb.BlogController do def show(conn, %{"slug" => slug}) do blog = conn.assigns.blog post = blog.get_post!(slug) + visibility = Blogex.Post.visibility(post) render(conn, :show, page_title: post.title, post: post, - base_path: blog.base_path() + base_path: blog.base_path(), + visibility: visibility, + authenticated: conn.assigns[:current_user] != nil ) end diff --git a/app/lib/firehose_web/controllers/blog_html/show.html.heex b/app/lib/firehose_web/controllers/blog_html/show.html.heex index 28cb722..8f4313b 100644 --- a/app/lib/firehose_web/controllers/blog_html/show.html.heex +++ b/app/lib/firehose_web/controllers/blog_html/show.html.heex @@ -1,4 +1,23 @@
← Back to posts + + <%= if @authenticated and @visibility == :draft do %> +
+ Draft — not published +
+ <% end %> + + <%= if @authenticated and @visibility == :scheduled do %> +
+ This post is scheduled for {Calendar.strftime(@post.date, "%B %d, %Y")} +
+ <% end %> + <.post_show post={@post} base_path={@base_path} />
diff --git a/app/priv/blog/engineering/2099/01-01-future-test-post.md b/app/priv/blog/engineering/2099/01-01-future-test-post.md new file mode 100644 index 0000000..792be17 --- /dev/null +++ b/app/priv/blog/engineering/2099/01-01-future-test-post.md @@ -0,0 +1,8 @@ +%{ + title: "Future Test Post", + author: "Test Author", + tags: ~w(test), + description: "A post scheduled for the future" +} +--- +This is a future test post. diff --git a/app/test/firehose_web/controllers/blog_controller_test.exs b/app/test/firehose_web/controllers/blog_controller_test.exs new file mode 100644 index 0000000..658cb70 --- /dev/null +++ b/app/test/firehose_web/controllers/blog_controller_test.exs @@ -0,0 +1,58 @@ +defmodule FirehoseWeb.BlogControllerTest do + use FirehoseWeb.ConnCase, async: false + + describe "GET /blog/:blog_id/:slug - status banners" do + test "authenticated user sees draft banner on draft post", %{conn: conn} do + conn = + conn + |> init_test_session(%{}) + |> assign(:current_user, %{id: 1}) + |> get(~p"/blog/engineering/hello-world") + + assert html_response(conn, 200) =~ "Draft" + assert conn.resp_body =~ "not published" + end + + test "authenticated user sees scheduled banner on future post", %{conn: conn} do + conn = + conn + |> init_test_session(%{}) + |> assign(:current_user, %{id: 1}) + |> get(~p"/blog/engineering/future-test-post") + + response = html_response(conn, 200) + assert response =~ "scheduled for" + assert response =~ "January 01, 2099" + end + + test "authenticated user sees no banner on live post", %{conn: conn} do + conn = + conn + |> init_test_session(%{}) + |> assign(:current_user, %{id: 1}) + |> get(~p"/blog/engineering/why-firehose") + + response = html_response(conn, 200) + refute response =~ "Draft" + refute response =~ "scheduled for" + end + + test "unauthenticated user sees no banner on draft post", %{conn: conn} do + response = + conn + |> get(~p"/blog/engineering/hello-world") + |> html_response(200) + + refute response =~ "post-status-banner" + end + + test "unauthenticated user sees no banner on future post", %{conn: conn} do + response = + conn + |> get(~p"/blog/engineering/future-test-post") + |> html_response(200) + + refute response =~ "post-status-banner" + end + end +end From f1560ff0e7a52f872fd56a95409e01881a7dfdc6 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:42:45 +0000 Subject: [PATCH 40/46] Add LiveView editor dashboard with drafts and scheduled tabs --- .beads/issues.jsonl | 2 +- .../live/editor_dashboard_live.ex | 113 ++++++++++++++++++ app/lib/firehose_web/router.ex | 5 + app/lib/firehose_web/user_auth.ex | 37 ++++++ .../live/editor_dashboard_live_test.exs | 88 ++++++++++++++ app/test/support/fake_blog.ex | 65 ++++++++++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 app/lib/firehose_web/live/editor_dashboard_live.ex create mode 100644 app/test/firehose_web/live/editor_dashboard_live_test.exs create mode 100644 app/test/support/fake_blog.ex diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 39f5146..936da91 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -8,5 +8,5 @@ {"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} {"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} {"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.675871251Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:40:21.809364236Z","closed_at":"2026-04-01T21:40:21.809364236Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} {"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.918341344Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/app/lib/firehose_web/live/editor_dashboard_live.ex b/app/lib/firehose_web/live/editor_dashboard_live.ex new file mode 100644 index 0000000..5f5898f --- /dev/null +++ b/app/lib/firehose_web/live/editor_dashboard_live.ex @@ -0,0 +1,113 @@ +defmodule FirehoseWeb.EditorDashboardLive do + use FirehoseWeb, :live_view + + alias Blogex.Post + + @impl true + def mount(_params, _session, socket) do + all_posts = Blogex.Registry.all_posts_unfiltered() + + drafts = + all_posts + |> Enum.filter(&(Post.visibility(&1) == :draft)) + |> Enum.sort_by(& &1.date, {:desc, Date}) + + scheduled = + all_posts + |> Enum.filter(&(Post.visibility(&1) == :scheduled)) + |> Enum.sort_by(& &1.date, {:asc, Date}) + + {:ok, + socket + |> assign(:page_title, "Editor Dashboard") + |> assign(:drafts, drafts) + |> assign(:scheduled, scheduled) + |> assign(:active_tab, :drafts)} + end + + @impl true + def render(assigns) do + ~H""" +
+

Dashboard

+ +
+ + +
+ +
+
No drafts
+
+
+
+ <.link + navigate={post_path(post)} + class="text-base font-medium text-zinc-900 hover:underline" + > + {post.title} + +
+ {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · Draft +
+
+
+
+
+ +
+
No scheduled posts
+
+
+
+ <.link + navigate={post_path(post)} + class="text-base font-medium text-zinc-900 hover:underline" + > + {post.title} + +
+ {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · {Post.days_until_live(post)} days until live +
+
+
+
+
+
+ """ + end + + @impl true + def handle_event("switch_tab", %{"tab" => tab}, socket) do + {:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))} + end + + defp post_path(post) do + blog = Blogex.Registry.get_blog!(post.blog) + "#{blog.base_path()}/#{post.id}" + end +end diff --git a/app/lib/firehose_web/router.ex b/app/lib/firehose_web/router.ex index 64c8d15..ff96fd4 100644 --- a/app/lib/firehose_web/router.ex +++ b/app/lib/firehose_web/router.ex @@ -67,6 +67,11 @@ defmodule FirehoseWeb.Router do scope "/", FirehoseWeb do pipe_through [:browser, :require_authenticated_user] + live_session :authenticated_user, + on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do + live "/editor/dashboard", EditorDashboardLive + end + get "/users/settings", UserSettingsController, :edit put "/users/settings", UserSettingsController, :update get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email diff --git a/app/lib/firehose_web/user_auth.ex b/app/lib/firehose_web/user_auth.ex index 497f038..96bdfb4 100644 --- a/app/lib/firehose_web/user_auth.ex +++ b/app/lib/firehose_web/user_auth.ex @@ -216,4 +216,41 @@ defmodule FirehoseWeb.UserAuth do end defp maybe_store_return_to(conn), do: conn + + @doc """ + LiveView on_mount callback that ensures the user is authenticated. + + Used in `live_session` blocks in the router: + + live_session :authenticated, on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do + live "/editor/dashboard", EditorDashboardLive + end + """ + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_scope(socket, session) + + if socket.assigns.current_scope && socket.assigns.current_scope.user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + defp mount_current_scope(socket, session) do + Phoenix.Component.assign_new(socket, :current_scope, fn -> + if token = session["user_token"] do + case Accounts.get_user_by_session_token(token) do + {user, _token_inserted_at} -> Scope.for_user(user) + nil -> Scope.for_user(nil) + end + else + Scope.for_user(nil) + end + end) + end end diff --git a/app/test/firehose_web/live/editor_dashboard_live_test.exs b/app/test/firehose_web/live/editor_dashboard_live_test.exs new file mode 100644 index 0000000..462dd9b --- /dev/null +++ b/app/test/firehose_web/live/editor_dashboard_live_test.exs @@ -0,0 +1,88 @@ +defmodule FirehoseWeb.EditorDashboardLiveTest do + use FirehoseWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup do + posts = [ + %Blogex.Post{ + id: "live-post", + title: "Live Post", + author: "Test Author", + body: "

Body

", + description: "A live post", + date: ~D[2020-01-01], + published: true, + blog: :test_blog, + tags: [] + }, + %Blogex.Post{ + id: "draft-post", + title: "Draft Post", + author: "Test Author", + body: "

Body

", + description: "A draft post", + date: ~D[2026-03-12], + published: false, + blog: :test_blog, + tags: [] + }, + %Blogex.Post{ + id: "scheduled-post", + title: "Scheduled Post", + author: "Test Author", + body: "

Body

", + description: "A scheduled post", + date: ~D[2099-06-15], + published: true, + blog: :test_blog, + tags: ["future"] + } + ] + + {:ok, _} = + Firehose.Test.FakeBlog.start(posts, + blog_id: :test_blog, + title: "Test Blog", + base_path: "/blog/test" + ) + + Application.put_env(:blogex, :blogs, [Firehose.Test.FakeBlog]) + on_exit(fn -> Application.delete_env(:blogex, :blogs) end) + + :ok + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/editor/dashboard") + assert {:redirect, %{to: to}} = redirect + assert to =~ "/users/log-in" + end + end + + describe "authenticated" do + setup :register_and_log_in_user + + test "renders the dashboard", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Dashboard" + end + + test "shows draft posts", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Draft Post" + end + + test "shows scheduled posts with days until live", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Scheduled Post" + assert html =~ "days" + end + + test "does not show live posts", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + refute html =~ "Live Post" + end + end +end diff --git a/app/test/support/fake_blog.ex b/app/test/support/fake_blog.ex new file mode 100644 index 0000000..2515f95 --- /dev/null +++ b/app/test/support/fake_blog.ex @@ -0,0 +1,65 @@ +defmodule Firehose.Test.FakeBlog do + @moduledoc """ + A test double that implements the blog module interface, + backed by an Agent so tests can control the post data. + """ + + use Agent + + @defaults [ + blog_id: :test_blog, + title: "Test Blog", + description: "A blog for tests", + base_path: "/blog/test" + ] + + def start(posts \\ [], opts \\ []) do + opts = Keyword.merge(@defaults, opts) + + state = %{ + posts: posts, + blog_id: opts[:blog_id], + title: opts[:title], + description: opts[:description], + base_path: opts[:base_path] + } + + case Agent.start(fn -> state end, name: __MODULE__) do + {:ok, pid} -> + {:ok, pid} + + {:error, {:already_started, pid}} -> + Agent.update(__MODULE__, fn _ -> state end) + {:ok, pid} + end + end + + defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key)) + + def blog_id, do: get(:blog_id) + def title, do: get(:title) + def description, do: get(:description) + def base_path, do: get(:base_path) + + def all_posts_unfiltered do + get(:posts) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def unfiltered_posts do + all_posts_unfiltered() + end + + def all_posts do + get(:posts) + |> Enum.filter(& &1.published) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def all_tags do + all_posts() + |> Enum.flat_map(& &1.tags) + |> Enum.uniq() + |> Enum.sort() + end +end From 2591796d826203aa3fa7337363d74cb029ac85c2 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:44:12 +0000 Subject: [PATCH 41/46] Format editor dashboard LiveView --- .beads/issues.jsonl | 2 +- app/lib/firehose_web/live/editor_dashboard_live.ex | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 936da91..fc03446 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,7 +2,7 @@ {"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:39:26.605057721Z","closed_at":"2026-04-01T20:39:26.605057721Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} {"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"} {"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} -{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.635424214Z","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:42:49.026878069Z","closed_at":"2026-04-01T21:42:49.026878069Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} {"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:39:21.420987916Z","closed_at":"2026-04-01T21:39:21.420987916Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"} {"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} diff --git a/app/lib/firehose_web/live/editor_dashboard_live.ex b/app/lib/firehose_web/live/editor_dashboard_live.ex index 5f5898f..48f0dd9 100644 --- a/app/lib/firehose_web/live/editor_dashboard_live.ex +++ b/app/lib/firehose_web/live/editor_dashboard_live.ex @@ -72,7 +72,8 @@ defmodule FirehoseWeb.EditorDashboardLive do {post.title}
- {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · Draft + {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · + Draft
@@ -91,7 +92,10 @@ defmodule FirehoseWeb.EditorDashboardLive do {post.title}
- {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · {Post.days_until_live(post)} days until live + {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · + + {Post.days_until_live(post)} days until live +
From 8d40e09e90d06d231b1a61e08746c586b1faba1e Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:46:59 +0000 Subject: [PATCH 42/46] Verify router respects date filtering on all endpoints --- blogex/test/blogex/router_test.exs | 33 +++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/blogex/test/blogex/router_test.exs b/blogex/test/blogex/router_test.exs index 0e5f985..f082674 100644 --- a/blogex/test/blogex/router_test.exs +++ b/blogex/test/blogex/router_test.exs @@ -9,7 +9,8 @@ defmodule Blogex.RouterTest do posts = [ build(id: "first-post", title: "First", tags: ["elixir"], date: ~D[2026-03-10]), build(id: "second-post", title: "Second", tags: ["otp"], date: ~D[2026-02-01]), - build(id: "draft", published: false, date: ~D[2026-03-12]) + build(id: "draft", published: false, date: ~D[2026-03-12]), + build(id: "future-post", title: "Future", tags: ["elixir"], date: ~D[2099-01-01], published: true) ] {:ok, _} = FakeBlog.start(posts, @@ -42,6 +43,12 @@ defmodule Blogex.RouterTest do assert conn.resp_body =~ "first-post" refute conn.resp_body =~ "draft" end + + test "excludes future-dated posts from feed" do + conn = call(:get, "/feed.xml") + + refute conn.resp_body =~ "future-post" + end end describe "GET /atom.xml" do @@ -75,6 +82,14 @@ defmodule Blogex.RouterTest do assert conn.status == 200 end + + test "returns 200 for future-dated post accessed by slug" do + conn = call(:get, "/future-post") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["id"] == "future-post" + end end describe "GET /tag/:tag" do @@ -88,6 +103,14 @@ defmodule Blogex.RouterTest do assert hd(body["posts"])["id"] == "first-post" end + test "excludes future-dated posts from tag results" do + conn = call(:get, "/tag/elixir") + + body = Jason.decode!(conn.resp_body) + ids = Enum.map(body["posts"], & &1["id"]) + refute "future-post" in ids + end + test "returns empty list for unknown tag" do conn = call(:get, "/tag/unknown") @@ -113,6 +136,14 @@ defmodule Blogex.RouterTest do ids = Enum.map(body["posts"], & &1["id"]) refute "draft" in ids end + + test "excludes future-dated posts from listing" do + conn = call(:get, "/") + + body = Jason.decode!(conn.resp_body) + ids = Enum.map(body["posts"], & &1["id"]) + refute "future-post" in ids + end end defp get_content_type(conn) do From 74a1201b88034b7f7aa1adea4564a758c957a9a2 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:47:15 +0000 Subject: [PATCH 43/46] Add integration tests for scheduled post filtering in Phoenix --- .../controllers/blog_controller_test.exs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/test/firehose_web/controllers/blog_controller_test.exs b/app/test/firehose_web/controllers/blog_controller_test.exs index 658cb70..a20c29a 100644 --- a/app/test/firehose_web/controllers/blog_controller_test.exs +++ b/app/test/firehose_web/controllers/blog_controller_test.exs @@ -1,6 +1,29 @@ defmodule FirehoseWeb.BlogControllerTest do use FirehoseWeb.ConnCase, async: false + describe "GET /blog/:blog_id (index) - date filtering" do + test "does not show future-dated posts", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering") + html = html_response(conn, 200) + refute html =~ "Future Test Post" + end + end + + describe "GET /blog/:blog_id/:slug (show) - date filtering" do + test "still shows a future-dated post by slug", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering/future-test-post") + assert html_response(conn, 200) =~ "Future Test Post" + end + end + + describe "GET /blog/:blog_id/tag/:tag - date filtering" do + test "excludes future-dated posts from tag page", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering/tag/test") + html = html_response(conn, 200) + refute html =~ "Future Test Post" + end + end + describe "GET /blog/:blog_id/:slug - status banners" do test "authenticated user sees draft banner on draft post", %{conn: conn} do conn = From 88ec475a5b8bb4af0f508ddad78c062d2acc4c19 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:48:38 +0000 Subject: [PATCH 44/46] Sync beads: close all 12 scheduled publishing issues --- .beads/.gitignore | 44 +++++++++++++++++++++ .beads/README.md | 81 +++++++++++++++++++++++++++++++++++++++ .beads/config.yaml | 62 ++++++++++++++++++++++++++++++ .beads/interactions.jsonl | 0 .beads/issues.jsonl | 4 +- .beads/metadata.json | 4 ++ 6 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 .beads/.gitignore create mode 100644 .beads/README.md create mode 100644 .beads/config.yaml create mode 100644 .beads/interactions.jsonl create mode 100644 .beads/metadata.json diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..d27a1db --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,44 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..50f281f --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..f242785 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fc03446..87b2735 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -5,8 +5,8 @@ {"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:42:49.026878069Z","closed_at":"2026-04-01T21:42:49.026878069Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} {"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:39:21.420987916Z","closed_at":"2026-04-01T21:39:21.420987916Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"} -{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.881106002Z","closed_at":"2026-04-01T21:47:19.881106002Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} {"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} {"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} {"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:40:21.809364236Z","closed_at":"2026-04-01T21:40:21.809364236Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.918341344Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.87799142Z","closed_at":"2026-04-01T21:47:19.87799142Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..c787975 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file From b6ff541b13aaac653ea2ab9baa9601f6469fc0c8 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 22:06:07 +0000 Subject: [PATCH 45/46] Fix status banner auth check to use current_scope phx.gen.auth sets current_scope, not current_user. Use !! to ensure boolean for HEEx template and register_and_log_in_user in tests for proper auth session. --- .../controllers/blog_controller.ex | 2 +- .../controllers/blog_controller_test.exs | 22 ++++++------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/app/lib/firehose_web/controllers/blog_controller.ex b/app/lib/firehose_web/controllers/blog_controller.ex index aa07a62..60ab50f 100644 --- a/app/lib/firehose_web/controllers/blog_controller.ex +++ b/app/lib/firehose_web/controllers/blog_controller.ex @@ -29,7 +29,7 @@ defmodule FirehoseWeb.BlogController do post: post, base_path: blog.base_path(), visibility: visibility, - authenticated: conn.assigns[:current_user] != nil + authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user) ) end diff --git a/app/test/firehose_web/controllers/blog_controller_test.exs b/app/test/firehose_web/controllers/blog_controller_test.exs index a20c29a..ce8db37 100644 --- a/app/test/firehose_web/controllers/blog_controller_test.exs +++ b/app/test/firehose_web/controllers/blog_controller_test.exs @@ -25,23 +25,17 @@ defmodule FirehoseWeb.BlogControllerTest do end describe "GET /blog/:blog_id/:slug - status banners" do + setup :register_and_log_in_user + test "authenticated user sees draft banner on draft post", %{conn: conn} do - conn = - conn - |> init_test_session(%{}) - |> assign(:current_user, %{id: 1}) - |> get(~p"/blog/engineering/hello-world") + conn = get(conn, ~p"/blog/engineering/hello-world") assert html_response(conn, 200) =~ "Draft" assert conn.resp_body =~ "not published" end test "authenticated user sees scheduled banner on future post", %{conn: conn} do - conn = - conn - |> init_test_session(%{}) - |> assign(:current_user, %{id: 1}) - |> get(~p"/blog/engineering/future-test-post") + conn = get(conn, ~p"/blog/engineering/future-test-post") response = html_response(conn, 200) assert response =~ "scheduled for" @@ -49,17 +43,15 @@ defmodule FirehoseWeb.BlogControllerTest do end test "authenticated user sees no banner on live post", %{conn: conn} do - conn = - conn - |> init_test_session(%{}) - |> assign(:current_user, %{id: 1}) - |> get(~p"/blog/engineering/why-firehose") + conn = get(conn, ~p"/blog/engineering/why-firehose") response = html_response(conn, 200) refute response =~ "Draft" refute response =~ "scheduled for" end + end + describe "GET /blog/:blog_id/:slug - no banners for unauthenticated" do test "unauthenticated user sees no banner on draft post", %{conn: conn} do response = conn From b2a4cdab429700ae41fd2af742db3517035ff178 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 22:10:45 +0000 Subject: [PATCH 46/46] Add scheduled publishing release note blog post Includes 7 screenshots demonstrating date filtering, status banners, editor dashboard, and registration gating. --- .../2026/04-01-scheduled-publishing.md | 72 ++++++++++++++++++ .../scheduled-banner-draft.png | Bin 0 -> 29637 bytes .../scheduled-banner-future.png | Bin 0 -> 27024 bytes .../scheduled-blog-index.png | Bin 0 -> 86775 bytes .../scheduled-dashboard-scheduled.png | Bin 0 -> 15746 bytes .../scheduled-dashboard.png | Bin 0 -> 14063 bytes .../scheduled-direct-access.png | Bin 0 -> 22493 bytes .../scheduled-registration-rejected.png | Bin 0 -> 27393 bytes 8 files changed, 72 insertions(+) create mode 100644 app/priv/blog/release-notes/2026/04-01-scheduled-publishing.md create mode 100644 app/priv/static/images/scheduled-publishing/scheduled-banner-draft.png create mode 100644 app/priv/static/images/scheduled-publishing/scheduled-banner-future.png create mode 100644 app/priv/static/images/scheduled-publishing/scheduled-blog-index.png create mode 100644 app/priv/static/images/scheduled-publishing/scheduled-dashboard-scheduled.png create mode 100644 app/priv/static/images/scheduled-publishing/scheduled-dashboard.png create mode 100644 app/priv/static/images/scheduled-publishing/scheduled-direct-access.png create mode 100644 app/priv/static/images/scheduled-publishing/scheduled-registration-rejected.png diff --git a/app/priv/blog/release-notes/2026/04-01-scheduled-publishing.md b/app/priv/blog/release-notes/2026/04-01-scheduled-publishing.md new file mode 100644 index 0000000..0c34971 --- /dev/null +++ b/app/priv/blog/release-notes/2026/04-01-scheduled-publishing.md @@ -0,0 +1,72 @@ +%{ + title: "Scheduled Publishing & Author Dashboard", + author: "Willem van den Ende", + tags: ~w(release features), + description: "Future-dated posts stay hidden until their publish date, authors get a dashboard to track drafts and scheduled content, and registration is locked down to invited emails only." +} +--- + +Posts in Firehose are markdown files with a date in the filename. Until now, every published post was immediately visible. That changes today: posts with a future date are now hidden from public views until their date arrives. + +This was built in a single session using an agentic dev team -- 12 issues tracked in beads, executed in three parallel phases, producing 232 tests across the blogex library and Phoenix app. + +## What changed + +### Future-dated posts are hidden from public views + +The blog index, tag pages, RSS feeds, and Atom feeds now filter out posts where the date is after today. If you schedule a post for next Tuesday, readers won't see it until then. + +![Blog index showing only past-dated posts](/images/scheduled-publishing/scheduled-blog-index.png) + +But here's the key design choice: **direct URL access still works**. If you know the slug, you can view the post. This lets authors share preview links with reviewers before the publish date. + +![Future post accessible by direct URL](/images/scheduled-publishing/scheduled-direct-access.png) + +### Status banners for authors + +When you're logged in, draft and scheduled posts show a status banner so you always know what state a post is in. Unauthenticated visitors see nothing -- no clue the post isn't "live" yet. + +**Scheduled posts** show a blue banner with the target date: + +![Scheduled banner showing target publish date](/images/scheduled-publishing/scheduled-banner-future.png) + +**Draft posts** (unpublished) show an amber banner: + +![Draft banner on unpublished post](/images/scheduled-publishing/scheduled-banner-draft.png) + +### Editor dashboard + +A new LiveView at `/editor/dashboard` gives authors a unified view of all non-live content across every blog. Two tabs: drafts and scheduled posts. Scheduled posts show a "days until live" countdown. + +![Dashboard drafts tab](/images/scheduled-publishing/scheduled-dashboard.png) + +![Dashboard scheduled tab with countdown](/images/scheduled-publishing/scheduled-dashboard-scheduled.png) + +The dashboard requires authentication -- unauthenticated users are redirected to the login page. + +### Authentication and registration gating + +We added `mix phx.gen.auth` for session-based authentication with magic links and password login. Login and registration pages are accessible by direct URL only -- they're intentionally not linked from the public navigation. + +Registration is gated to a single email via the `ALLOWED_REGISTRATION_EMAIL` environment variable. Anyone else gets a polite rejection: + +![Registration rejected for non-matching email](/images/scheduled-publishing/scheduled-registration-rejected.png) + +When the environment variable isn't set, registration is disabled entirely. A demo user (`demo@example.com`) is seeded in dev for local testing. + +## How it was built + +The feature was planned as an [Allium specification](https://github.com/your-org/allium) with surfaces, rules, and domain entities, then broken into 12 beads (issues) across three phases: + +1. **Scheduled posts** (5 beads): date filtering in blogex, unfiltered direct access, feed/router verification +2. **Authentication** (3 beads): phx.gen.auth scaffolding, registration gating, dev seed +3. **Dashboard** (4 beads): post visibility helpers, unfiltered registry access, LiveView dashboard, status banners + +All 12 beads were executed with parallel agentic workers in isolated git worktrees, then merged and integrated on main. The demo caught one bug (auth check using `current_user` instead of `current_scope`) which was fixed before this post. + +## By the numbers + +- **232 tests** passing (89 blogex + 143 Phoenix app) +- **12 beads** planned, executed, and closed +- **3 phases** run with parallel workers +- **0 compiler warnings** diff --git a/app/priv/static/images/scheduled-publishing/scheduled-banner-draft.png b/app/priv/static/images/scheduled-publishing/scheduled-banner-draft.png new file mode 100644 index 0000000000000000000000000000000000000000..6bd248855d22c9ecc9526ef8c55c736a92f879a0 GIT binary patch literal 29637 zcmeFYcT|&K*EWa+3kZk`2nZ+^Iw-xP5(Gg&dIzQV4go?@5s1>HOAWpE4uODz^xjE; z(2-%TcuvqKdEBAfcKKtx_oony&?Y){J6$KLo85tSX zTP1)d85ue0@$$%x%cSBZoAM=6amhnd@ikfb(1T4fvWH}E0k5=u(st&9jK|l`iTl%{ zGh{4<5hF_)zvAsKTMcRN{T!>>u{1yDmY5us&ch7G<*^;xdROzxOg-9;)9v2*X=j%K z;L(u#?t4|8e9bNXbG}e)f}pmjg6Ir}U{QGGmB#5ulv|sdl#Syw&aZ93rOas9WE$6e zZ>>P>25B^8WYbbs9t{6_da3o0?8?Ox{_>^!7v&-#l=PBn+T_rCLzI^#|Dm2^vVzk0Qs*75SihaXr+)_@YI)f%kIaW=cMM$;Y0MFw{p8wlZMXovf8WH?&@k zJ>UdLFA#OWjWXHqeUN(OS8FHgskI$apfCTKtMIpROq@-h+BdMG^hYOn-7Tlb6dIiN zWOno;?dImsbQxOtk*hg{pme<9Pu1 z)~~agAI@y&R;=GZRqAG`xG7YUXwg6LlqANg<@e)UU;W;-4jcnd2(i$FgPv$W2gVyIlx7 zqrP%^vdnXrDZAEsol~I1RkTBE@E@(P^4M~JzVtC=fO6} z$mBj4j9nM5+I~a^RYys|tQO8~VqqCgd)gih|I$YGWnKXfD7V6s*K8&ba>%wy*2rcK zb7{jmN#J@`R=&OlcOl}LTx#xq$f{_1*8T>r%T8(~g;bA^Y1HlcjA&qjxYgm5%-kli zbGJPm|KO_bYESqmKA4sEW|fYx<@|+KxrVeDorl)hm1q|;)!XE0($O!}*RCe@OKzr!H+<8aPzw_@&stauIWytH%*NA-COEdO$Tb`2W zZL>fL!)Zmmfn9In;XD?Gb4JcgTrYn}rl%`D@2nRb^vac1^#wUxXX&G;Ybv7odUxclNjt#n$%e>V(1Rn&i~Sp%=G!adH)ni2?lDlE?Z?&x938o2F?PUQ zeB0qH{`^Y5Q@x0GP^1%XB11>44Eu>TZoJx_?TE|p^h5M7fmLB&FV#OWkn`V-Sp*Pf zN9IH4Dmj9WnzlC?(XcU3d7uEmFNY^-{wf@g&Ck5Cd;IoRO_+qIvpGR~}31ICks zm?9a_5i4|zot-@~!2(31CM04wsHWJ2-9&~1=wIMXzR=Wv9`q?uLy{vjz=AB`noOw1U!V6qvnNj9)O zkMRxc_<$`N0GL?PIF{wMUJRyDVBSKW->JRX(Jl96KZ|P8%t8xX2pYuuEZvS2*`g9;AhWx*QR_L}q@P+K}mT-b-0bOrT>xyyFzsPWA83+(igNf!p+u+d$`on%?-N z)Wc(zHs*osYs z5?ol$kzrD6vT2?ptFDj*pnrc0o4;;(G4J{E4BQSW+9LVuBwhP3@Z|v%3>J~vt%Bk(WlTIM2`MA$U42X!4iT>K z1O)%I+D*^v=V(jJkcsOq6P5q~co7Wm?$0#~~&=v<1< zL!_SOe*+iREiEnWnukTaosbwfrKff9hR4UlWI$M!7rwI#OmwEMr=sJ1vozGKBc$G- z-Fa3BKxbkqW@bJBv@i$!`<2{47pMb81}Xrht0u_;!xGPDw&3^C}&0L z8~EOCPfs-K!Aa#$Jj?8c^@G14MtNY%ncAzBq+Rc(mP;q*PIIgw)kA>%_uTU+%Jnz6 zk*g+>VhaISlMYSP?j1y4Y3WYCK-<|te??zkU$ghFpySNQBY9Nw>JmdzdMg5#-O_@8 zcS=XS+H722!n;x}97q;*pAmPE&n)Sq*VLT1t3ocLXXzD%1g<714wfV#D58Coo#@!j z4FVTW6)EM6fL(?=;*I#d?>a*iVFlSI15tX>h5$8Z8F8S`$Hd2uWBEqsF_WkDBL^*O zA;ZX^+&5Rt#PzFvPR^rYg@vv@+z-kj4d(}N$L-Bxn&BP4Wki$u0|A!3R9}Oe09m|u^^pqrBj23%w50lhyY1}6W-`2+n0!9VL*S(}(>Hn&)f)b6NJwGW@O1XN z$F2O+L&2Tw+f`_#;kRi-x3e082rS2K$Um-**b*(5YQa0($UDz#ZVGj+YQmP7odd^f z-Wn4A*4m(_M8^k=}I*P}wc!;s%BXVi=N zcn9Gi_mdH1yOs^4{w8ubm_Y6(aE>gGHp>uiNmX-{T30o2L=o`akTQR1&%>Qs$L_4i zit;*+HW&fPCM)+yqPeL%bui!m+a?PGiZ)tN+Bv}g%YvOK-P;16V7R>~;-Kd6skf3# ztFKGOWUNi%->Su$_aRyFlG zPSMq<>p!(!oWqE{;hdx`Q;eLx_Fa2N;%s!v9$1pCA9Li5=h=2KtZda9zCjyPoz8LC zilpL~t6h#$-kh`E$(^Fj`h0eYg=5f(mQ%9|-B~?56~}&EPGY}C{_^85h>-6}p8{RG z3(RPycWW3V0b3>)rfkdd{^?Y~CL}QB{Fhei5G+qD|5gMw`ku>(#I9~C*j6}O1A1lB zxz6y3uyo6M&>}Ocu>lC(6Sv@~%UO-@NJ}LSfO)R8Ur+KWD9DSgTS6lT}cG#IB!Qk#)-4X@B-SW0h zp+wc+O(4u}E9mI2oO9V6VL1&d)08aXjnh+MYZX}gEcOn=Uh~HWT%XuK-H8z`J{(Uv zmuYQB)#?Y*n=}A|EwD(45#FT)rqu|x^YH#wv)IakCV49lgo-k6SlRAMoH;LJP|#9P zn^J;N1i12CVTLjQOY&{hGLW6rMcHq$N_Av>Zkg>jHb3UHtR{hlJK)DKwZTfbMQuPwxI9XLaus5DFV_cXy4wCv!AK-UmnrPjtsD$>M_Jf9;ZdWrY_(F)b@JF zVxHO8<>#qUTZIFn{RzAKFBUvPx1X8;I<7;d|qV-@*y+*6$Uk($fxU_#CSZ_HnfYqV9U zg|wAgOSv(RNbvZkwo7ky6w}JCIt#96<;pbC%4CX;AFi!V5n|j}e;6X|jHUDUOprb< zDrGgSw{_d{ocxNg=>9H;|1d~G#oEPZ0-HOu8sLH@Z>91u2QD(mU3ywhc3OETOi&vz zmp{8k$FAvU6hOpoF3{Ig*s*1fU+tp0QCt%D5^46mpR(k-2I^m<)u|D< zmkAmf(cMjC!_6*LIrAB`)&T%n%p=wlb5Q}EPH>N>7X&~ila0pcs~W6ai+oPD!+hY{ zNPgt{$=ebL%l%>vhdMZAj=R_B+ukad(z%nRUo)elQFydSSFUSEX5g8K)B-}(wd-NP zTi8?Sw`_roPYuP&hNJv!*S5JN>)w>pse}!#OoxLNOOWN}y4u__jB8O&&vGNLz zQs)Ql%Yz0}FKjqG9Ig{i)kEXSLFs32@ZaULwEc|B`;=`mn}<3xwYg=HRr83(E=AS` z98wC%nZQ69Mmola`)@&!E)A$_R?U4bdX)i8;>)VMz2i1w&Y`GIU>K@cg}UWwz@ zdSl#6luC-tE+f3Cf27-NJO`qmA9l1)Qbq?JDp-Y(%wltmnx!*=0~@dhiPX+djbZf$S|aU(nkgo-SB%pf^AN?`S~n$^zfTA>~7?h1t;I=OXS zja1^ud8KQul>JDzGb16H=dDL5sYjvfY%6quvo`&$iol)oplX zBW8Q;k<)x#J4+Pr)<$trujxEr~ScShD5bE>0rAu z&S;eoPQasO+I^qt0UaOY-Dnbz#TqjFt(beiaeh*2A7k9?+czR+v0#eR7K^;+GC;dY{=+$fgj`7DmY#ZD>L#usn|4OcFg@ykKFi5) z^T@021p(W(O{~L=&|K#)eVz5=Co^AA5>(?e>5MF*sr#3(eqSo#Z{^;lGZJ42=0e^>jd0< zl?47u71hG`I`Dk8VA`5Zj*C6(*Zj2x;`n#9hV;Q+DvgU>ZFblw;|yVoR_t8aZyfHT ziSFLTfd7IDZh7J@2Al}jkBNlUU5z+Xh{XGq@zOWA8UdMwA(;*Dh<)Gg$CK*XI=$rS zKFnXc3g*fXN45tSHG(`7B(0Y4@z$zsli{U)0T|u1wm8j0Yh5V5CQ@btp60X&Tl`TI zB+PJx+>vHg|1dYvV9Js+Xu8+^0-HZHSlf51au+hafsP&?T`(&_zEfW@HF^2z4d8)| zn#n5fz8Dg}b0|=LvY1m?XkRlBG@jM*okrFVbIKqP8z{pXorCu@f%wfjVn|d#WGiUi zQwxYL)?0X5ac6nOkI`iw#y1Y6E8!6`Ukw0KH68xOUb%9mRqDCPsy;gM=KJfWxvIeq zDO@9e=yVX9@U%SYZ+lTcCFd5p-FO^X3&4iCzcr5aat3rYoOBK_F?ZMD^aJ%t@V=US zI!o2Kmyz1yPxca-t#u>2b-kWO6_*3%taO;~HIaBnc9!n$2txLUKWfvz^e7vSHr;J( z@(veAhwGa!yL|g8+@?xN+p$P*6`jKHRaebN6KL`E;7ArXEEkfV;`peAo?iaU;r3vlg=XB`sSLPTOvmba)LO&7mxMQBF;A&Z)IjykLM-O zsgal>w{Kn<_IOo~X$uGA=x!4Ow%H_|`%mO~ zeGrKud8+RDwhd?Ejz!Ij+BnX(5noza18fV6u9Q zfr}(RGBSI~k1|{CI$uK?92nv2v5Y4NKGFrsD$3DBcZ2J`VL=svyCv7|-M&4;EnUqu zJR>~(ub{!FATBwNv-sVw;M#55_r*-^sIq>Y)BfmFs+o@-c8u_6oZ`7$!zT_t*Zvh+ z2y_yD^=M(vfpF>??7dTU?&v&1otgst-JndDdDDLEbok%Q@jZ%=>^#u{sM)9;Vm}nF zwpF_e$Q##)4xQ#ld+sG>mC;d23AM2PoB8LQNco`p9t=*aHRMiD(yu9&P$@6|st zHK=*zpI``+hW@r|p5RNset!(1K&8Twzji}=bKeYjrUN#bW2Z<^rkmopkcV=TS= zFIqRUZ`T{4X2`>n#-8y~wX)o)Rx_^{UB#bt%-|7{(-Pz0C`=L3IKL2?dHM2WR+*== zRSi|=oqq*ZGP%;YWlT*?pRu#<*G?&f*cx#GG&bmev62qZ1x_5wDuK>hubIKG0YhPC zHz}M)jfEh?|A{x`FL@?H>eF4Dj2FzVT)rG^__a9L@aom#E0-tOH4QdPOI^i>(!A3o zbPPtxwyj0DLcrb7o|@k)ZjgQqx6aFpIahD#Q2kp>nrk*{N$K@X!(EHpKQ)W&v#1XF zLjw5z9Rm<@)zKACUn8-zh&6Z69BTu#iKp3~6d#FK4$&;qEE0M3c`NhNr}p*oBBuX@ zjw-3{T&UUqObqG24@2GH6p)&n6S?S!Y~=6dFRu+n|A`-w2}|a`{9hmC`tL{h-v-M5 z|0e#gD< zbDVGf(NI;5rU=!vl}Vp0OJL#78f%V}*m)7D@?!Lj_I9sKP^VC;q~|?1Rteb7$HVF& zKI=%w!j>)mnkJ^g5*2p?lKs`@AZgDh;v=#v)3jbV-M0;#dJjr(kAin6%JUM-jMRz# zoh(xv=D)XDnnS*W)8-Bjw|o`bW;ZByN(lt~PmuGz1Z<|+r}3#}V1pdhjeE2)-%Zs0 z8_Y{e%(r20D~g>#t=(+6#vpw!pFf%;?-t_x$$4iEvp?CDqK7=Xi)svc8q-|0R%XW4ERzx9d z3;TMKT1H`O#l?8Au}dx z{yR*}`xYPa!1dSnJSX}ATdwQTEeCsU^K<3lir0EcKA83rdxFk&DYbyJ&X}=%F*PPv ze*0^fAVU84FjX^Z6e|6)Neil-r(cJrLh0`~?04;6`$FTwYZ?9P%`cdENj`o72;! z9X*&T{afv$0;Ory%4A8VjE;}mfqD8@)|^ZXzdj>j>-I?tTHZybwW8A74bVU*8v3@_ zBP3T>1u+dj{tM+4+svZ?cwI-(G6LT6GQdb&eylweY*nBDGqb9r9#Ox~IQBZ8uQ_bq zClFshCX61L0%Nqg8nmZq2@(zl)$SwM!AM{SpF{!%HHN?|5!@7msnHz$CV7Rw&j z9sKDHUWmhtl9)t|a80?|6*LqA-+!BId%5E^4<#^>UMjwy{pEx@y4YS zb$U94e2r~(huy>i*AobnT);R67ef5JR`Py)1q1)mlwMXJy3I~;^xgBav4=)^NfFE`&+p0JEnN-u^I_~ z0y?c^mZ`Vza%Fw<|$1n!?`yE7_0&bB&` z)a#_tPkf{Ou$YoVadY>mpsnHWOwRTZ)*f#xO!y97iLfiGs|*Mq%F@)`p`PGWSUvi+ z0twJW5n2#Eq93KAOBejQxKQKQ)%{zCr8-OhK0g-=fL}*Skf@Kb-zJlxMmis9mA7Gn z1z@Efry@Ser1O(Mv<>19@Fc6B z#s4^+u67S&>K=#-1HP+U2AW?SR<^ve*r}tHKIj7!mSkP#_;Kb5$BL=ot6T6AfHakF zFeyP}vEcrtYvl@X=YY%d9rHs%%3gy@Gw>arzyIx_aPmmKg&au8B|K-Ue>7oK4CHIp*69f-S7?+^!OI z_D6U}v!*!SYHtp zlnL_1fVcnb9;8(CrX8CkX6;635NVdKlmE#NDZk5c{t?f$x_dH_((#+LrtNi~xJE8i z^S`)~|Ka{0xBnmB{11de^W3%UK%0{V$vNMBkH6%%M}AWYsiUMtjjJGS$p*o(vL>B- z#q0dnU-Ic+rEH|D^!CfH$K^uKyqU}XF56e^v5v) z(%9|$bb-a(G~7JYq%;?@;tSt%M?Iw2zo2?@alM@6kBs@ON~Cmb>chJy=R(NDqseX= z%g0B#+NZ!1Zlr*m=l{y}Jq)9O;y>!UK*?KTsT9m&0 z+?7hBvGmUEg*hFP#kqE;P^ABJV3Bp6zFM}o_38_|GE7WQBC_{ea=Z(O_^&=fCkp-{a zGyhJPsj@Q-fF&~AA@msvt`*1_Hrox9E-Pl2mGj zLEvv4d(gA}_mw@#IpvzOr>;@!`30dvOtkJzB8~2{K_AjACOjUQ5eT1(-b+UOCE`m= z;NQXGnAV!@$h>tnwc_kEU}|NhOWO!~!+W-=#Mp8fdWEAtkP|e}Sgw=)bA0L@zHJW& zh07tW`4UIYYhoeqCdvE1VNs@WA0M2ZduVXS%q92Rq&S;a71_jtNlkAMcVRAa!)~>2 zw_fnk_-KC-hHTgdT)7E`GV ztn71^HpOrt8adc@i5`R?iv3g)Ir{uj8F80I0T4Kgf#_ur7PL7>A7@YCx9^gy+jN-C zr_bBr@cZKeV|IO2%>YuMcDDo=J%2JiUqlpoZQ`#zi7Q&IRSnjXGDHR(q+X5zOWi@e zD1KVHTe%UN;ZttYMt#Ro_?_v<2JC!`N1C8WM}QvWBGM}2oGD`wrYyK#^~ah}-R+J9U~SKg7$0yhwWhX7sG%jv z&k7a% z>NyrkONWsAcyQ7qgctyAea|lw{`VwAQz>b zt=U4bE#J-f`QR?1ZudvgeD=FAnDn!Jo$C~jq3_Ne8jP0V7>HvdS7}rYX-h4_|2&Xg z^waXj|NP20T+T~(?~ZBRxz=ow9l|WAK%a%d>W2F_ZOP$@l7@(HZ2Y~&+ggK*c}40+ z6}xc{6OTsnm?rdEW4#liZqT&t{B>xH zL|~7O&ON5lSMHQfYIbGxF|TniH0r`{(MI~LtpAD6;N*=5XYpyXbJG^&XaWG->2+wk zCkoeh_*F~5N-@6;yr^6PDNZXuy0yJ%z zn@{>wehAy2cyCJdtmG!8R{E@&rf09QpRcF9=Hv7dm=D2BVZB8jErT=jxZV)}ysGrx z7LfbktWBOA=hW?R6`m8q)^=#|%5*f;rVY*)Baa&YW}72fs0>`Q+2Mw_TH>nJ(_w=& zcn9>t0L_U zGRS6a!u>HQqi(6STb_pmu}(M*ZNc1LvELD~xA)wuPme~X?`xPIUtyOwT3?eE@*juh zPZgmTe9aVTZ~nzXQ;_w4Cxf^#)aTwWR-nN@Kqa(WXIyNZQy`TDUC2hB zOIw*Wi8P4*r^P!VeRR@20q!au)1<+dC*oGE_4Zh@7uy;>JKP|?ygW@0{GgXqToZQd z7D;iE3p_onUNQhbB(3vbeJS$&K#L;g^2_z%T!@_ISxplAF=k#<>QbFNRRVO`f+ z_R93L|KQTS$$KPu|1W0F{=dXaHQorba_Ksljm|sTodx}EaYT?q5Tty+nh#sxU#%eO zpOx$HYvNBzJVmgB-kcy+_(YXF4*TG_wLzeT4k*@w->JhS|)WDI{6*YOk^likFa$l(VJB`Kz0!$#=VT1Z71Aq0sOdw(pOfE<7H_c4)k|yrcST%LP{i~gxWwSQr1L>4k6G#uA5k)g zTU>{_6&cPgnX8?Q4!Uh=DXu6}Tl4M#vnH>}c%M2jSTb;~Sbb`M?autn^pckdmQVQE z3#+4cLm^qx;I_g@=sf%=lrq$%ZnR3HPMWh=$oM%$={MBGljvnplY8Neg4|hMvuTO` z{p7ozSJ2H5FBuIm)ZhK~ZPEb%kn`(;+)c6Eo4!s$|Au^?0 zzhOxSF1&kZWw!1n$!I`Gy!)d11BE6=MrI*F)l-GU*gFNJ6x7l5I}t!C<^UJ>caQm= z?{N0bR*PDA3@u!)$?J4x@bdA|)^@~aLN!C@>`Hg5*5OZYy&J1A+{Q^))I1dGmwoo9 z!BNHA`WLw(9BaKH8GCma(i@X%<^H`{&-^uClA~07deuh@Yc*5Ytf4uuxX!Q)ruF)F z`=bsNOMGTYi7_H;CxU8Q)N(zgZe_tlX5k?QfAoh^4>jXZ?~o0mN|m;;c29S(pC0Vb)!yHh|~V&e*$27Tq7 zr{uoLq<;rwM9}lyx}sLkB-DnWIJJ}M2|l|{;wd}3y;GxFNj)O{>C^StdFyc$l?r`# zbViNsR{L4rVk02Od-ocW<#T#|sj*^T`=O#yjSzOq-SX`a%Kxo2v9^Lss+<1~a#(-G zo`rn#aGihd{Raj#%a}dOQ*YdMRmgI)SZ2#V?*67w`L5$k>FTcdU)3qFU87m{;skwMOx&D~FA) zfx6D$z_KvC2p47tOiLBb(0;y|*ACmlJj`PoLv&OkV|8nA zJ}eO&&NHKpx#O|EJZm$Ko|UH*6ykr7Ql9RGi*VEOAFF^(KMtREJoO*%0@~0x#Fiyp zI9s!;AKGc|p!vo7 zhIRWfT)mRCv?jkHW~qo_;Cht|Xf(cd?hLT_ex*Gn^-;pA^&0LdK6(3|*Vx}pdHaYu z$-&ut4+iVrUJ2f8k!OF-YM)o6$$B}x(y^VSP7$9hq@x|BN`w9#-iLA2?feuAIGyce zmGMI{3q+J>D5obLJ(cnd`YDs+XUNB0>?D>?Nf#g-Fhmd8_<5YO(e^?_LR7gR`bAWV zT+oD-TJ8mDD{%Sw9*;(>tXKQuDrHbtnY916$1F`MiK`i@l8*NU_wYRhS$ZvK{@<86 zD#UiuIRCGVgnG_H{4LXfmL+`4$N3*}G$k;-7^~gv1P1=U{#k~i^3|3bX)Fd_tIor2 z-ECt&Rq@6r7Ht#7N>ORig2M%db*5=UkokLfjTdl(t%&$M) z0z3)Ktm?Wv{M0x3l$|}_G0x(ReeEI^ly z^0!pdsWM#YWR%~kg6Rj=7oA0Z|8CtUbdjfEPG`I5r$^Y-vROZp65MtyQB{tDo|U;d zDNJoil;?J#&HpB3nj~%F@+ZydnmpeK8#gk>atU>32zI8$=&*0iJX)ClBGMYWJegV( zaJ`#PLc;p3tMf1y?I?4kY+Pm8Q;|@Qvcy>XI@{$tRI$k`#g@#{y zs}`f=V?3q+e8Mf5%CdZTzw)nncE{5K`0DBzRlLJ%3v>DH(V_mpCH^+)eNqy~$PW@U z_EO6s-Ax+0UJ`rfo9Hvqt+>1_4aqXFi;~F3szleVqsaJCjmayOYG!SGV3GZ|trN`N zC)X4}Ls3-C#^GSu{UMKEqJ84eb<_105za2&Z}P`??s@%g_d#AVF!6~rOgn8lbx%V3 z8J*d-g4M~UCK~4YvnOpQVL{rttOV=3$G! zXzlW^iCZ~VoNQg`#eA8=+q>A-ZP_dQVFFDGX-ri!##+t)22zKAWsVD=Px-!SRT^`U z4mC50-M;tYNtYgT!el42124bRV1JP;k6}q!;_JzM!X2hZiT2sYBFg-NNfVP7NfJpH zG%ninEmqg8qsa2`=;rtsr6UY2F~E4p3ONtVXuXu!e&;5OxMY>zWl?lbVf~2q@whE0 zl2tRnCO5p$O6UnK?M<{uF1+2@G4@4^HFE*jv-v~Z6T**=t;OgyqaQJLMZRh3O>cA1 z_Fpf3i23_jM7d_wa@r<{lhpgf#laZ0iSl>%jC2a4cF7Q0$NaMBQcZmU!bEUIM>4wN z_BI_T)3Vi>G^6MJ_*GM-y_Ao=e)2Io08ap@JK<}_h1%V+f--A@>z30bnSgC0fja$d z+27P7D%NNX11es<5J8SpC7*vdt|xcU+daOysc^b+SSjk(7qQx9kg7bsO_NhXGo7l- z*DEQPG^hK+1~yTyM;cloGihk_1_y&8Q>ygPrp&^`B?Fan@)}E)^)K?1*!{OZ(=hMM z2bbO#?q-<3pd8Kfb z7Q-OpDx&B#$CpP{hrzbrG}VOe(`40S20kVonKCK+x$Dr@b%@FFIs??@16w@j8x77uY^ZIN(n)Wd&Pdg~!qLf_#e(V>JJ0blp}hveIb5xT zTI_s(t-<UrNsovqAQz5Fy+g%nZ~=$Wa;eqFIVKTF!Em<1{WO4x*t>%Tc z2$N1+MhBFj+iF_sOh8%8_q8`l*?&Q_CoB6DhlL>=eS;*wnI z;k#;ex+URJkzxzUf@cf4oRC{qy(XzKdYVE8h(QBpq0mV6s95|_WHLE}wq0+(ek$QZ z-)Usf>${p}xYZ$iM5IFly5|K8f3;yXGDwKSn1Mye_2{dVA`P_vJyGV1zuobuWbb}} zvR;|k#DO;HQt6x4EVz|zPWE=7pI(yqahYlk*4Y61@n2xPsk6m*GIqz+#B;|adQQS} z#-#U!h_x(2b#vRkiDu}I#}Ks#Z2tL=3}qL{h*>u0Hj2Tax}={MmX+U-_R2 z?_9L;|Kf`He~l3Rw@oyj2ULITAUe&H_#R`v)Im{nMUamq`fL=jV^U?DxRZN zb~X83|AJpX&dLmw-pShl*l34GJ5;dDw6*<$wqC0skt@c=FG-i-GgA`KWtH?s z5dST%kk?BAHpe)$5wF7dxy_rE^xDjaLBnFm2b?o<7E@*uOm*17>T#=7(E#tg_Dgkd z4iT5GIPXrDOj&rq@CS0lSrc9FXOhqS{*HVkT~r6Gp6st|vIRNT89sjem~>TBfS0$l zy4t;eeee{C_JGPv-1=htpIm^Oq)bleui#b&{oxwpDuxWu5O(^7MYs3PNR^qnc{n{E z69%BS7)cBu0D9eMNte-;LtX<6!y0_?M48b!uSu2Y#zs=^m7yHPWT@$5U}M$kaKdFW z*Lf1dGo3AeoyH4RRRd0w4QOsWnVAIoh^kJj6={2&sD=^9CqsLt{2L9~CIh!>H`mr| zqRtOg%C6JX(Xq(-pQuNx-nw<`ompGpcH$rxcAkTsXb2VMf0U(;gb^2-JUY&fX$bq# zO%f7(M)m#&MFstReG9k^C@n2*!_kfbX);|26YEuPNWc*w~Xn_peTP}3#7`h<3muPsW) z70HK1U8gx;B>e4Bm$TZ1>*(tjYI5PQY$MQNrv=8Lm>4G-7D;_$WBg2RZvq{ik*LiB zao2~OO5?bC*?=<)9c@xSVY4d|xxN-sXD?#D+QZELIAC}tPbJBF7w&|p-<^{Rk)wz2 z7FhJ+_xI*)bLHIt+TV#1ge(7 zdz|=)-LS0Q2#cZ*UrWr8+4iM3Lk6ZM9ixLFTs+Vs#b_dOfzYjJzy^j9DSp24eD7c= zqOo0BB2<@R`^1yh_$n_G-CKsQd_OZ(tSWeAu3MR{g5S?EDzogJD#dHLfZEdKnb@baR5<~hQYC+B>as{+AfTl~MK~T4j z&|fxbFV4G|iKtPOYD4jw&wb+`y=Bw818unjhzHnlfJ%m<=X1P zQ0pN^YIk?HVb0H?Ti@#e)+OLDmCvQt*nRV$+5=$QqeW^K^t{cR7Px2HysEyh3ov=r}Q z$9+t&$*^-l>b-aMlPB~~M>5Sbwu7)JKH}WQGo)L$fLRcMt&OnDQ_)4URP#wo{w>@84{~i?R-r+GU1MHqCYDIT! zSG0NeK24HtwHp^mVT$X`YFO|_xDC!tqQq(-;@G>7zKR7MrXCJSRjf=BGhYQ?tfC`k z&|^pf>e0KCIvXy~wECnWcJTbqJg}jmp~AGT7^Nt878mnap!sh$gs@{{Wo5-O?QZ6! z^OhKT`eP*&RoyRutKrhBc7Zjmv)S{*PGY01Op7WY5{Kn7P9Rnsv<;eHx)TS%TWV>o z(7=cT={ja-s|B*h_%?ihN=nMC$NJ7}&4yV92-P<4f&L*BhCeu-mg0X#>~;ylHF|kJ zsLpVl#Rro{Ct-G4ah)bK#f3l!QBX)S@t%bR;GA1`>kla?&1PT$T28z3BrRp3B;q&Z zQZauvuhTp@F$-$tZFnT+1+V=ASRQrpU$Lox8EP8(yqaMI^;qO6Pxhl&%zjGy_AtuBP6xZ0n6~;ABmQC@QZoq`7RY19^-P;q zqU^}Oed*RM-_e)fcrz@qkv2MQ*0xzxkdO4;AbB^^v_y|7$qJqkUw3fX>@EI+z>daM zBjth)2gR^xIoERyRiD|lB;qI=VRWGtgheBR{9gTX2|V16LX2;|Q~c1f-^EPYW-iXD zKDfnS!C`3xWnXW1MKmo% z2n~tjc4{hEWbq8Ds{dPiUmDML_P*WDOnt5v5e`oT8fquduGsTtEi%COKR+b z)RrK~OsDvvC@pGDQPjQ*A<{CaC3Xpk#J(hm5)nyA{@*_T=f(5-c|Bgli|;4NIrn{? z>%Pvp?{i1tWqEbWa>?=I3NQE3d|11vKUlD5U4h}P7K#u4k`M{M2Ss?&@m0#}GI-#r z0Pr}Oi_!HMe*gP7@NeCbChStHlH9*d0zigS=v*{bd$Wz+ibCc1v=^^aIcm+%07tp{ zH@j>y2zp|kSoPX9ZGioVVj*#3hDdZ>MB97fb~k5;3#K6p-N_KwFRv9mPh2MmSO7P| zUY@K1M!GMh8oE}A8`NHSwVsV&e|sCht`07B%);_ViwKtW3M`=Otmlsx)Su6OnxCKd z)uOH3+pWrP7p0mRMgrEv0u1L)fo79DW+GSU{uynn^9f_jftbxGULytC>OZ~;y}MVq z#k46~c>E$*x4t?O)_(~RJZ*@aZnlite&d-JvRvcR8LsnRf=nGe76g3jH`45qc5Z0x z2ajN1$?$h+h-&ow7BJu0Q5)7}blag%M+5$I-r~-cB0GwBQ4vAlVrBCunRZ0M1W(KV z;g1$h5nETjmpBoshTUv7xE1=mN1nR7ejyxL$N9SgfAO~0uuur-6^Z|xka-kN(v;og z$ZWMhrndhkA&3V^OT=RXV6rH3rSf$ZX7hCLmrTzloIHPWaW`_q^MuaM_1{~Z*m`SZ z^BkM7?$-*6__T$b5VAeMX~z{JmgAN47!BS-c?QJ&rL|I*(j+s-rWbBS?bCYd^pF^5$#w3it7;x}?df>sQqqsSHjdc?{|Ba*{L#>>_sS zAws4lhFcDE4qHl7Jb3-4w;q^y#84Yi7q5aA90wZ@$x*|8IRF>~Kooh(6Dw<^Eq);- z+D{l02l3cM7(tK|G{{8e63(l;eE~vESH8Bh%X_}{dDjUK!N*^0CL z#ztbne1gKu4O*V%akJcS_@9ZyEjwahj~&$8+S+)PK*JP3&M0pZ#iC09T`u790I%rz zw*et>0xR0#0-V2Ix6^CDIN8ya;S>SHmT&rIH$o^n5W;9&2Q?{}UfVtpf=8;OxaGid z{uT;9|1D7xa~$sN?d{{M4LQ4C=kdfFMk8illG+u0LW-W(qAKk>*pH3y8Mb z%F2zjm>3ZI@Zm%9zk2>TcP3Ct{O#3#H;93woKznuiZ9Kg-q7{7t>5kV5iFWz=%HI%^N6b!Ew zkV+z~dFD?>Uj2=Py$-23lY)i9IAci&zt<8P#b0_cW{FwF>?bEVihTRtKD??CF#3Tz z!!KFEqS+k<1!@oTWQ-(DAE?EgkQEH-ZSHgH z+!bDKVa5CI88Aiw+RAD~^u0v98vbJjoG2W>6>N#1+8l7j_KzNY?ZNE#0C=;ql=4*1 zB!KA+?5!RkFyziBsAXB%YI+U7*BH_d5;m=1r)ykhKOF|vWnZu^9Z=1X#yx(ve>ie2 zz2}K&1F*j84JACFw&N2L%&OZ32)l>{z&NjfTW7P$i}CY-7V*4mJHf+72{O&I2X)sf z=9ZT3+_@v|9K40zB-rsf< z-+kOtB!Gw(3d~gV1S=Rue=1$>pOYPNa&;#=;H^+vBb=gwGY2+Yvnsf0K`zFD*8}J; zR?d$Shjepy88k%XwD8bABXlcTI_Ad01@cZ)g7RzaN0& zphH$CKLM{Mzkj>ocJjz!;3(v`GygvLUz2oR|4lS9{-`)5o-f>2TuGJ7E+R zIhaExW606bjRJO0?ircY#hjZbj3Cr zo(M_kv8?ddZ4u_JL(UQV3)!6P`M*U~hgTWzlhG1?URG7PFFk&H(?{xxv9Y9I^|)s2 zNvPMrj8%@Lf*VR}C6!qo4`CD5jUCzlz+6@WTcU1Ez>YA{c7~6wLRZU8ey_XvD;3yx zdRj+EPIGurb=${=Z8g{UE{Ng}5usYvZ}(n8(BZK}M{uIFJFjl6s& zFQ{9i!=j^nzHAt7eK&|(qCq}Umg3y30=8;XQZu(2t0SLZ(sn;CYq$yTgzS^fBoeE` z_hX5fGrt%7e9Gu^ZBUtZ?gO{%?E3tMn=i>R#ZLUy;7GdilznN(Ne86XO1zmmp~$c7 z502c1(c%g$9H z`eIKSiN44x4uw4rTAO>~SDC3Z&*mO)1Px~0^e~xv*Vh8irXu~rzq0#ME}Oo}X?wOY zc-e~9U+hSJ;;iFp4i`#sstJNV0J@Gwk_tplZx_lgNk*r@x1*s&87EJyPVB!5<)@jq z23_)iMlPk<%@9Px{AiPQmyM3HTbE7Wn!js$9t2;Rk0n3R`eX5BS#_14m(+nZ&dY6O zsX8Q~`ymux$*yfxgu=7}ydwcXt@$jQ=iMyuUEGTc%J4U2;XfH73;X}>+l&aD+u3q= zx63epr-ch&o73bri-u|6$x0M?2XS-aT<^Y3Y;*VErW|))lgrkNjIf_dNg#Muk>oJm z>@Q_$AvC+F5C?H=Esb`mT)F4Qv<(sYdGXfeyQF zX`UT}G0w8e74RDuZLJ&4OK9Thr?kDTJBtGM5s5ctgr znIL}yRt@i#yn`0xX3#v>-Tm@ii@f^g-7oKgA(uF$Z1_1+j((Ke)Py_WRTLv?Umqe6 zjGo3i^mrKr_DFzb>xHtWK8ydYyM-HY+Fwj5N%ThyA+q!WsqUKT1Pwb;if;fF+8Wpxx1uaopugz3TkYw zx(@D{q`eP)XFN2pCT*8^T~Bb!AEv!55aJrZ)mccu>lrH3a^X=mfcGe>S%zDJy1G-Y z#cCf*A#JH9Ui7AQb{r)m%^Bjtc6CPW?OCw|AJJK8g+-Kq_;Yr{E&4-|F$M-R@LT-n z4h`>n`&3|vCg&yPw-bjdFm67d7hui}?PlM3g4Ny`qRgYF z_a`n(S0zWBEz1pAe}WwB&GC*eKYK;eNc2aICWM+%M2Sbf0t3pPKb{us{S zHhlg-&F$QH+uk}AEd>i*%`d6X-!zxDEaz|4Wgk0Mg`SO;7wr`_*2f!rh5fNRP{b{r zQYKfzaErTN_u7~sw)V27<#Jx)kgBTMJqw4ePI>X(lBu%ZF^HXTcr<1kE$3VxXcgw) zIqy!>g|Zv;Zvox^mIn;Vv0*->)a;=0Wfew*y7Xcr7}8P`Znsod0-e?--d<$M<%IAS zj#y_@VLa&DI?;M;tax(n;aUJVJoFJslLr}o-GW)DheRN|rmaeoepquqh;16>e78Z9 zcWAg0G?cXxwX=Si(l~zO8y=E5D^*9WGa$mz{-LTo`#`8*&>b%SaW&9iUEH_bwU%nD zj70}iP?S^^a?Icw*fP*B#c z1lh(;7IOsS-(RNK`JoH0MZS^ycCv0y88i`c{e6TrcikR@I|2K;Do89}$?&ur$RSkJ zCYzu6_4JXr1ysYeHx|oT+4Tx9i=|m_m;)cn)i=y(S;mr;i6p*_Hy*cOtIcY62ZhKk zk+9DQR%EV{nk}6kj_$D^ag$ z91He_an}) zQA3g=bjF$sTh_dX{s~Ddd=t{>2!gPesZ{a+$u$9ektJu^tVg%ltQvAK7#ekzH-q&D zm$>i?sN?&X@GaBiC&ADyeV<;983OLZ`_(GM!CsFVvBAw|h@)i3o2|&~&o*1$C*c#} zL@mu+{eAMEyMnU`@*r#ZJu*%{^YK&Om-*v8?J2Em*Fqx44M>D^#g##)hN9WsE9DeK z#7w6g3+xr|-GnZb;LN&$gE7LAXnLS8&oSAB}AZ4!5aGTxd`9MAHq|sPkEbbhG zJ6jMR9N=Q*y<~toPOEjH1ss{Sso!TlstsjU)Mm~XxGbRt%I;k;vLJgUq3kGMZtY3=|sybMt?l*GS57DcgWF6=f;JfO5@{$y{1QG1dYrj zN@|=*4~9c~Qu$FiuVtq_+bkg=CT%G6*~sMdLcf*_&zk?@(ev?0UH^*)2!3kcT&ByF zG+S^{VCzL7@bUfn?Z(}gRdc~w)=#x7BD&k+7k8<$&d}eQ!1}=pY3_C~dn7l_{W3nM zjrF?XX-&-^Pt{Kud4<&pws!j@&RHs}DBZBIyYbGRvA>g^OJLtk1}SBbV%%7x^Z9Z4 zop;U4Wo##V1jegyiTJB3PLEUME_MuWXPZiUWM}EAf+k1K$5@s<1Rc+>*-wghA(>Bc z;*&AgFBsiRa{M~mFzP1RJJ1X)Mc$mOq34KfWYZ0pvwXch@9c;i(clBZ@z2Yakh^=)PPF(IQc4+bjx!^@iA9ZyNYm@mz>92I@lhrlAY|Q+l^=? z)BiIjtVW?e(Uq1mbVt`G4^@v=)NFLnvaKFUY^@Gw?CsRJTsd3MF1>d)Sgv$Qg`yJv ztb5fZI=b^NDO*E}xYQeyhuEC#v>uYilUe)M&!0GpX{qb4dJ2^nl?6<`+ z?%hx5;LBr6H8HQRRm-GR&>9GZ?MQz**+Jbq!YbLAz95Mz7cwKK>z}CA?|keT<*C!X zhf3PpK9nu)ZRRF7Wo^kQ&i%qHZG^rC;aPz5389z{A&eVzThGHjxv@Pe8rB zQI?m+)V(E@dREGA8inT9*zgajWBqME z^W&r=OPm;aPCTKqG{5M*Td?43LXwHcj%{yG_C=aev1`kq-Q^>3i&pYKzrA%oSAAQ;Sh*U-NtU1u*Mms*r;%h8``42E2(+Za+i86z{-+o&sZiOP zFHrQkB&EUI-Eq;*HPuU5bv=)kVVpGjJ>Y&ZZhsA*_WA<2DfHk?GB_zW?P%f}*7w>y z%Z;e4V8f1rhf0|UKLhrkT9nOWrPr5oc`9vX`e%Ju*~WM74`p8i{#GwOFt%pSPbV#> z`!-c+Y6{Bo^FRLT+}y@aYub)kjt~smtO}p!ye^cxTf8&+qQ^0tqcE`|h^61E_F3DX z!rgv_kMx;%6+b%om5J1fIce1HE)?`&ryuhkxSFX_^5p61khYD>oHhzwFQs&%tx)HK zVj4zIaeXg30RD3Z| z>7Hg-w?g+bvE;iWg__<%*Dzw*E-12)sp>Bg+bSD63c1>MCa zK`zW38%YK0yc3{JFCQPj>lX6-A1XxhNm2bP#`k3Ww++cy8H4KOsZu>3d04&Au1dL5 znH>tN7Tew8M=Q9_LAkNB^gNjdnH{oHh^Nm}UsN#mK%fRcxpHai~3(4lJO zhJU$Rn4myfM4gwz8zZc{qIZ}x0=c5oSoB8aTE;|D-Yw16<@_bXtP4T0iJth?UG$P^#zS@LV3x;3{@4-Mm zUOqbwLQ}gRvFNdaEc_H~4St~5{=I@5wh9Uhp3#^%tH?QqFLBxbGZswro9eqM#EO_n zqAC?}-)@Z4T=N2^_fgr-Xw6)7%z6gs;Nj&~SI3|)HbvodlY)lre!A~3DXOGJI1Z9K zRO0F0k^gzfzp}Fb$|yJ_;yZG$Oj7$2tBLTFdc8)Rs0w1G-xkRD8b6Y7b(#=PBNQs3 z^@m$jG_$vXLW9AJqR-N3DgoB3ia57e>Z5disfV zgnYus`FBSjSp)-|~}T^XW>LU_v=h6KA{W4HHC8P=`=wQbvT zo|RA!?-FSG{a=q6!B=8+H~szAEIkx|Qdk6S{+|4L0`G!2m!keH3I?;wX2crl!8ACF-C+2wvC3)Nn%S1G+TqAB4vP=+FGG0K?QzSZF2~bp^@_46AOF>&(s`%O^h%Yfq} zf9yEdYb5r2s6`a`duq`}8^SiSK(oe%2?(rR%4AI_l)GGGySw!bGnBG_c`7g<`Y)6n|}wsrk6y0(F4NVuyyHLFTJBD zmnHtqFA}arb0UV3sCXweTi+7GJYU#P?)s%P5PKXXY3Ps2ck!2^YCZ}yeDGn(}u zLP!Qu*pIX+YF&_!%IZ0Rp$7dvx0?Onw1Sl``g8O8wism&!9EMmH8N#xWtW$RZ*yJS z@ys98JPwN<9et)fapoHl(OY&_JFCBsEsiY-Y(;^TnfhH%ova8Ry_5TbJJwgHwYS(s z*;zCiN8#mbCHd?=V24-+1n2;FF6#7H?0oO4gEYLRju>jZ6$^_C{A zA{G6StB@N=ufZb92!2S_1s{KvFV!LU^&~=}(Jjbxkmue(fV`1Zh zlZzz|I0LQwu-Jx=C91o3*PebqHk>UV8tf9bTias&&8oU#l#V>i+3@>I%^X(K?KGQL z25m{~Siujb%{?SpPQ?W0hC#uK`!Qv<{Y_Pv9)`Rlq%>HBKObop>8mJq&uC(tM7^zw z40X=ujVZYm+ra&TEcditKaj0?&LbVMJhDW~273h>Dqv;^Erzjk#>)OzkP~!6D3_7U<0loFO~%(5#6Y zWhfJ>hvXKbf^D1(TMzOxIjpM_zBy@ZPP=^C^m6prlOmA^x58L0l6EnJlqYgyjgZXx zFv(h{a+8C7mcS(0mJhQjD?}~9-!Sl@olh5@{W$|xA+^@Ekjch&^ks*OFfU)_i?-p+ z9sh`xX;8T{E@&9rA2go;RaaFa{6I&5{Y1?+Z8J;5b*HzDw!JWI5^UI4 zx&+)-m;e5MQ&9h=sQyjP{hOBjHzE6PD*E5-{J(Vx|5jxDTcq;;Rh|^>&| literal 0 HcmV?d00001 diff --git a/app/priv/static/images/scheduled-publishing/scheduled-banner-future.png b/app/priv/static/images/scheduled-publishing/scheduled-banner-future.png new file mode 100644 index 0000000000000000000000000000000000000000..3f5f203973ff840c13e563c387fe03c1da607a14 GIT binary patch literal 27024 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�)(?WG3j2aBUw$S`gj_$&Pon&i_qw}Y@qV7{7a2>>-G1ctY~-E+QKKE7s7*qh zoZX~L*Ho1O{*%W#uTdT(UFKTY`gOJc#@l4vsQBxT+ys23VQWbndvpi32nvZ{z{6_8 zhD8JZoIMN2pV%*B9YT?2i2m5OrV4qVuxbxbqv7q!bfy*2mL#E|&fsHghnMeHT&&$n z+Z}Po_Y2$XfUC81eOI>nQ8d^c3nBQ%d`vNuBe-u(=6a1!=U_r?bDb-_eas!h+lu+b zjG<)wV#gvz>h%$7E;co5RES7YK1VY4p9JL;*_MY}Ei=dMk)2c!Q?!^#>!n5tRW%g` z5|+f?zPb6iHnf@dX)`Sj>M$d3E3&78&;u}1@^Gk4svWZn*^+!Jy<0o3~`kEP`! z{l%vKpwKfb(#ny@k~<^V2vH2=L@+h^b*TT7s#UyEm^GHX2T_Xb<~XfX&bu=DY!Px2 zca;46Z%OW^51+JCx#Qn)E$8?uj1q`TNl1rL8%QRtmK-Q*C!Yt>OA*Y=;6$ z7&m1&COc<|su{Uw=gP3OeW_~J=BS1Dc9q?7h|d8>a;N7=(fuAu8h6Ec6PW=GrUU=tPm6HZU$_l9L~9E*yqhiLMZ4>}_I&fQt|=zb zRV&}V&YyGJHmqTd@}wByLpKq7d7%@yP8{wljZ5OsBLFK z)K~j%TVW}uBJ$t-Rt`Nsy;&AJ)ot8)oQJ&oH12Y*4zumwv(WdE^~;Xp>}~5CvTXIF z^?b-XS>fu&6b_vC|tC!Shg~joNbt|zwEaLHn1sb=!@e?LV>bS z>c(|4WP$k=arF0x&F9xYa9;Ic{l)Jo+@_^DWa!_!rN8=v?&g~)ZK!^`dw!j7{hmUP zdGK#&HysTE??0dabqbB`Ba%mGz%C*U7CGYzz_SM26k)R7M$ZC(=Wtkhb`fAuY}PT=;G&=2LO{8lal*b`ZWq5b|o(2gD1Yl29}>K#dIzVb42v{sd8h z=7^liWM`+OxCj>N8@6ILs0(3?!$oYj@9x`G+Z9XvDCv zOhSJFSVb?HnXl!ffJ4pq{sfe5XrDhX#UdnR*7r+GGt}h&D`@9uW6DvG^zZR;RX_;( z`xBFr4yG@iIirDoD4GSL6~GvL=6)al)i#0Xxv^ccsC08ri=m!KbDMXv2K4Pav$HS# z5PwNYbzNe>-A%`0KoqS3y7NOcVxXX}t*wF8?)-Qgj4>xF!t|P2g~Nf3O~`h9bAP*) zo(M{48xEQW0OO!lfzZ-j8b~+)Hl}OC*TxUQWBmSg}Y9?%*`!)`GPgAS|bY{P;fBx-IC(d z_)&JY_nX_BbBptob3c+4C6)L~nTU&viTMC!&wd@ znDCD>GSnu3K2j{da%@qgNFy&B7TH?FpM4me0cv$*H;=e(XCz!WKyDOvP7wV1=@w;Z$(U*6F@)nPZ-olleKkbt*K z<-UGw7MY!`zC4CJs^n5#5vY4+Q%ecGa#t<#Hq(XBaQs9=44Ljwd{AnC+f z1T;31Ev^4g&x(x=E=gkCv#Ia;lxtxLeJga7mhfE}Lzyy4*UWRYB31H7I5@b~N0+<6 zpj%v8h~~59eg4XB!V}%ag{-jfWtwR|3kx3o`}_|xwLc@-f#!aOFAfMf$U_Jdb4GJ= zazgiD{{S7zr_uKU+S*2Cfq{9ipV|FtFXx2YgCRCXM*aM-yN4TNMV7({r*k>~Yr68> zT!Gn~(7$lY;*t_aI|n^|T{&ag);8b{pNC1saBP9QxqHBmA6kTjDm=~?>8+xOW|$1t zb&Cz=a#;^c3tyoE)wn3uwezy=kVRa?R}pfUtbV`S$yKmaX-z^&$;fUCQ}w{zt#&wR zt@FavJ>%ry0P2;GASvRj{HUV*yJeUQ>zOn&)7+WdUD$Fx)`Y&Ey86>1a~JjMq@;yj z-8SI3J|e;yAp4$Rf}uYUQN$vEfaQ?tfr#He&Pzx*zktz>SHO%|-*T01dqXNCAaHSQ z6|Y=OM^91YuTfHa16;|a(q3;u~w?|1y-X6_&6;xzQVAZ(L_7+-HNXGZ#D9TlO8$gD3tl&~F0)j2u? z$&QJtCA@1K_2Tw*3|U!Thq0x$)^<{N9eusB3QS6WVI^k#?=UhB^vXdrqcYV^w%Q1cd5n;_?qOvlJuB z$|XpBTE1^?2??-fqa}$Kwip9Npy82eY7!={2Rc0@!o?wIg9RvPMi>R>Y+1%Br?VJQPK6?jy)W2^kn{ikz ze|+ql3^UEhxT(FhYk`VEdX-ey{44Wig4e(m2U_@_KMx^>5A^r16CZKXq=n$ktgJNX z6}&QfqOJXqii*k_9sQ1*rkR=K8@s(lU(GsCg2#`U@$XN?3+!3!m2XUrP3)w@L`h^= z1l!A(@HXxl83Vgpy~`Yqj@KO5=7H?ce*m#8I^2xR!pvX4v`!MM2WQjRsB2R6ci154XF7G}N&TI03+CD9cI<##J-}@j2ahT($dO{a? zpDYkFb@1e)$7&LIi%V=@ad6m<2~{8ckkq#lGlX_CazgZI=b)p^whIP!us}oO@lzYc zeQ5(-Z2^=AJ`@vZI^6{FH6BFCL zbY33)voj_5_A^``3fZ)n>OYq9Z9Z`f--TWKR^$~>jBda2cmwpkUm=$L{pA};^P6@_ zX8QV66ciC*VL;BXwz4TMDmqxW`UHD!T0!8tRvRCmNFXI!KXFnPre$5pD@?Q+aol1F zZyzfu!11GAFtR+GM9)wavlNSt^E)bhQ4VWM|LRzKaD617qr}tG6O4R8!Q}zM=xIOx zW1iN$K3O@WzCNRB;n&liF0DMsY9J#>4KGnsi|+5Ast)){K^iZuB7M826;ZeYHC{N157{^uuJUew;kjzIUqr-ct3t*?!J~NT z;ihM(B!^C3(6d-fUWCrX4nIHeiC(;La5#l1`Ub`Om1{Ml2ZW8Hm+YV}+NNMj&0@O)!a<;Y~kj>-uF2sZmgjUQY8Jn1V zf8~04fyoP;e`uzZTb!_>+rbR`r<7}{C6~q3MMV|4xy4O}n%uYKKyipBiiUg>)(iDG zqA``vgD2!eBbw12GdlR&sKl!fljVnVDIg z4PA43=GjUDe|=b)Jk+D(kx z%BkfLZEUz>iT!i(1JNi=aI~jK=DqA`rZw;Y?~I^-tFZgi-!3jE7vxZm9WA-Gy!@P& zo|cAgU5o_8u_E5T_eR};ZXVo783#xIwInCd*Y@}Kp|J`Sd~lb~oZ$&h2-23-LWo)V zh>-tqK44U*@A@;=v~+r43^7rj@~zr%M8y01oK=t#1K*a>wn=Pgd|W)Chm)(~y^0D_ zwdT+P0}a-#^Jka$pInlYl8PKU-lyM^YpiR_C{46mI@}T$mjDhWa**jhpE(rstP^>MJ(BC1)D>f%=W zokU|JCWmqSZgg~K{eNh?U~wHIYb3%sgHQ2a>9ub`Mx30;36#Z(QmaUlOf0uQDB(O@ z&{NyEI`Q`iPRfW}t84Uf$AV1HK0Y1~oZI1SD=e&Zb~-(llsDp~KPZ9S27|c{V-|>y z+b5Pwmlck9p>0GkSt+;o>GAPI!FzS<*ep=0lczz))75Z@-oNTX3U9TW?qQN1b;PxDs3cYW+>;-2AVG-v7`9{QtzJg0%>r|3CRz|2N+l zM=X-q8%*qMbur4istwH32!#wkVlK+D4@XB+a>TBq6-&!A8nJU&z+WS#*NCxXOc~p? zHs&p}pKD~nQ2+{!;hbk{D;h!s;WWM!>rA6kWO;T7Y^o)cEyh{ZV^FQ|+qZqhDEH<~ zgK%5u(9nRjO7^^?tDo4cLR(qr9uUXio4d;AEXNtE)arl)>vYl(;^&8f5Yg9Pg8ze^ zjqPxJJ!xq2-lSZYnsrs+e>i0dtJ)D<-y`xH&Su+T<<~)tzNSL zNP_7Z8EO7d>zQtBfC5oIdO8hlRb|yF<8P|v)z$424ApF#E(NW;Qa(}#!}{4ls8~;x zkwdv@;RLZ;zdtackYr`$Y;j&?OvwF*%NEaD-K!$>R;~~4)?JMG)U+x){G-iD2{AFX zlN}l!XXoqdQ{S}4u{q52_3eOpENLaBP*HNmB$^vH%(pRC<1_0tAICj2io8ZXD9N1;PnMiH zAY0#Kyo(71P@1v9cOtGqzGt7XU<|t00bEsyU6z;U1+5=lO$})vC-WPchf4wH8FRfl z^I@#%XJ-n)=^Y+E9;vx}gK~1v9j}2&6almCugWxJfJXvrYjJV$t4aeN~jVE93D6Y(&8a85t2>Y2U;84 zMqQsJ+(u9fP?KtG7Fs(9*@5H~7oYaRaQ7#PfS2q-4op7JQf!e^)-TPEs}!9+oDtBJ zmDN&KruOajyia)87>R_8o00cQRa!+1rOag`BB7vf7nAp=NYYYgjD*D7mGxCCkZyQE zE4a~8?KeppKS*=GT7_JdL21UDih`V+k`j%Wx3Dljg3Y;n{b;|OH5N1`$mv;GxoCYl zX2fH5m!AYjov}D?O~Ba*Wfj0Y8yg#bev(jO_xB7WDeCI!xO;h^{{5x1DdB~Z(|KNW zRaLFFuD;$u=0}PY0Sas?si^1l)GwIXLMVcmiHL~ShBCun7!QJ86DkSJE{KYX44)1t zIh>xl2W*TLb8vCpm53T1yZFVF2x_`CWu6ab=dd4QS0C?YKs0Vp_~Z)tD8kNC&2i~0 zC$14Q-7QBA`v z)E_m8+3wcHgHDB73(qR}f%?d-y_gBF>9A7wr+jH8tjxFG-b6$jW@eka3HB8eME5D> z2K&kDn-&%pPyT0B1{+!q65h*MkKv*y~^n7Emd zoz4lem&*3ajw)P5aU5y~YDzU>g`2 zdbG#X&7@wqELwHAo$<_V*{kKf17g%%omb6;O=fCb_wVkHljkQejqG3D>d3KF+o`Ce zMEICMBAj_(U_wGszJzwEhVQ&Pi{=jc6JU4NWhiC;Efw`jZ@iTA^K^ep5K!TP5T79A z`UhiN4APq1jlTy3=HM9;xX29zT(WDDm8B(exWZJ3%)xJe#cO$+OCf_=)73+@Z20O zGKR_JU1OxaE+HXdO~HCqaE79Sf}LBr zL=NlAJ*TG*BP4!AJls0X-KJx<@5Y7`Q&OO|&3b>)0!cmv1!Zzd(gOsW^I^DK!d$ne z09RKoY(on9c5v6}_r7K{fVt2wmik|hqE zE;rStl;q``qnl-r#8b#((|af=udHYPW>t_qDWmNV-E!XY=flOrp?7pWp)ww{v9fWh zv7};sRQ#-Fy8BP8eh1n}neov0$)2^9wNj3w#6Tmu4u5RuU&@o6c{D+-*dUZPJ(tkos$jP>{Rc7bAsPrYcgzW#d7 z*d^a1IG3I)G9zw z$MH_uxfzf7c}ZY`kn7pRA&9F!f_o18dr7>u~f3JR?Om^meDtIL6PKa#ro_7P!i=j122o~OS|H9FH4gUx&})%)wR(tW8z_|G3bG*y zw{As7MA(>3nSJ<>Yu=Y!g2zy9v5;?qu+tV0kk^@SS0)jgQd3laPm{4dDOr}7I1h?} zdjfbSBc}_?O;^IniB2~@DcuF&>ngV}Ke1;;Ay@$rWR>>~#kb%v$Cp->7hEXZ9r;2O z%V&G(1jErFS^(7f*0wej5&=(i$h4sXOLP%`Y`#A|Ab zQx+s7%vov8&Qgex$6()!XVRLf4Z*bsFs-OMv?MgAhL{ATmYil2y$O^UA0ouX#V_;C zs!II*lY$38z~!BI@*(4Xb+t?NrHg!^RE|0i$*W-@y2G6VYg=0w_9Ju89Wl8R^ydQ; z#}f>n1{k_*>*~@d(({FEv&gptN@?aCeGubR4?a@y-kdF>U9oKn>WYcxLSDW@G6p6s ztaDhZs!hPi3oDOjR}B5mPk4%k(gBG0Yput)v&kI(E=P4Ub$*x2z6>QU4h|>}GgXVq zvx*LYW$7M}--{hDO-``TqQeZ{3EiWk2$UE2$6u;kFiZU=%06Cl2eqgcIrt6_4i0pf zE@p05n>vVaquslgy04(5Lih}whOU{){Sn^ejKy$-VE@*UZrl$mCr{REd4~>C_gs!A zMi=UO?NJ15rV&|L6PerF{89npl%^i?lr1M z!BYyw2@DJ$V#%PPEn@F6U!K*{228vzSrX?P=p$bC>9>o2_3t}R^N3JdU)*#_$p!wz zy8xs<51hve(b>S@gv1M{@kyU&m%BWDFf2pTemn8Nsb=$)qh6{e)c;0pm_Eiq!l2H9!=Z zgzSYXb01%sU_^H49XXPBh?UKB`l*Gmw}pv&CIu&f5(#uOtx2s4;piRvGp_w<=Fi*?T^3zN zIt}OFtxU05hP^pySOOI2|x*e5WexU#yL zj3L>TJrP?eA(2>VvC^9rYX+TF0P4RJtqqin{Fv2Jus!nHGL2VHmp4k19qH-$v$S`2 zR6>G+^=NbDxq8KqYA%o)tHouz6|ov|+4GcwY$ zvak<11=y)(CltsjD-)0i&aMy;<3*nBnc8|c!IY%3I4-M-^)pTkyP66)bwxne2DftN z9%Fki^^o;IlT0@$so2H_V5Pg5=OQT7&*`kpt^3y1YNqgb_-+Yk8w^|arg=w{OGytG z=GJ~Wr@wO0uZ5`R{m`+aWh<#NQ{0hm$bMb~!%I$4G5Nx~*L<0#we6PMmzRta+}x5d z2{GLL?2-4&dlcAR^NHlN!{=H5;Q}B;`1wC41Qm!5sJ#myercx4&tLuD+WV@osQPeU zKKTSx1OyZW1PK8_N~B9cKv6)tL6J~WI))G(=@gI#K^p0nROuSJL%JDyX!bkb{`S5& z&vUNM)qc+Q#(6+zX07#K|MkA_uR=mX#H%E_|H>H8$x~+~PfYxVH>!&;aD8>(v$8te z^ksLlgDhBCY=~7ku?v&edU)Vkb{U>-doAu9s=hMR(SfJ zI?Wxsr#3dm(v8&lR_XdMGk#m!4qEmd4MHG@gY)Og$6wa zQBnSM^$ORE-bHnM zypKJr#mTeXSp#P4sOx)n`JG~7V#IW!!~Olv83pfte(<%5T-i*c<#9Y?5}vvB}ag&+||V7W_I0HpaBPGJY2J z<2`|MD|7RVSqgF5C=q%=!G}}_U1R4hKV15TP$9pk!6X_}^F1#L2nRXW)J18z)_+|j zCVtxR>r?->mvCEGn=-5Lp!jiAZCxMuI+gy&@Ic`EfQvLhW~Z{(A4CrOHoD%Ja%u** z$;s_@t{?~Ke&ZOR5pw|KjhL-Nxm0VQIZ z^77I_4$3=N%GA_4DJe>JLM?zqIW&re``VkSBOcGcP0_1?3#B`GFS9)Glpg)Xr*}_UfyCp^CZi#_HvJdN8J@$c~*I@R>Kn$ zx9BL27j&$lpdwB-08pOZO$R^ae6bB2@L+sC)$UCf0WA*)Ti8Ce&$C_khnc|& z*ow)k4|amyN6W6*3UIWEP7q$#w6|{>$N~L+qqOwh*1B=h1;|{A;?m0o2a0!RCyl$iH+g|#PZ$&kmXK_0 zY;tmP`yMs2oM;IjYAPx~2B*#r7pn#aYzhm7M(W0?TSrIJzkdA#YR{mnS4kBWA;2cz zah6(b`sfxXM0gubNWjJRt(*Jc+QwN)}nOT~X2M>IzgyS1w;RA#}{>_S#Qq zpC*w6pzzr0ez(iwJ3&}={TFlctgNQoP8}c>Y+_{vT|3e)@PIINzd}uMc zU#SaPJGf`K*UME zP#KUjmKH!Eq)W6nM5gESY|->9+O&cq`ANe3N!*X_uFb{m zIk&qmj@XB|t69U>aF)U21LVBkqCGFcxqf}!5`kIE0{vA^Zq2(hw|w>zL{DLR7U1}% zD1QD2kQehMBEt7E!>#q^#-`e>QK(|-rd}7~{879(TY~euY4T@sP0fTPD=>o6z6gfU z#MtkzWx_nv;t>_O{anj6&lrQ9wn5**>(#lH^w4g(K^C^L!X2qLx%yC)uj>2%s+y3{oe2b3KMn<}8 zS-hqS#|H=Z3<^_*I<TvjHNT4FK6wnQ(IC>>Y0DQd5uF3w8>NYZZ;Eks03fX5xBt=(t1Z_#?krNr|KzH z2alZGHmY44*+u;3M=~CGqfTLI01b^1AJ?Hp(uKQSh(qy!(eVTq zmjn6B0VU=n%Dm9dq>8Pvr6?%kVIGpE zC&;xGv1C_|&rnpNd%RZ6!_y2=95YKx$hk!vjK@#{v3XC zf?u7Yob>tVln?`M9To4UQw|O)Qj1MB^0WsqN>c7u?LVC0UVvJy5dLGOZeB=|!4yg} z-<7|%=e+{ac z)~8SV_7Dv0;jM(Bav?P0nM*HTFg7t-e23W%?^2TV1Mf}CTTk*)k<0bJ3lhJ{m%Fvjdz0`+xO596Wg0%tQb12nFDhy0ulF~$ceZPfY!^jzCRdM# zo0|Rhi*G2k7WfLemPjQ#Z)as>MnRG8e(>`+M?D{WNrjStdDdTYo58Tc{BO$f+_m_M zVHGax(Z?5mL5sk3hCYwi)4jo^b$>h!JmR}wZSdf500RWd2^w+xH@y)B%_7~u=pM;i z$_MBwh`FdGtDf!lad7&qF3o8wE8A|I2(33GE}Vo73=TN5l6T6>BS|9V)YP>{yN7RY zg)SA|1sbT$KkwR@DC{ubd&fYcZgb>Rw3X~nk9M`u+m4=Z=aWaTfVJ?E8&-tDNcBiZ zM>R~+VoV2Tv~E45fC7o1kKbdfF*tbNF;dRh+#IvpmviTvs81Cq4vsTxG=lIyaWwl+ zmH!$mJk)6Ums9({uK;)be?1!^#4bvs)46deCV@#uCf*;kQ#0Fxr!f0JSeSziYU*q& zR;e%4tZ1kjH@KC(-CNrVHwcb8t=+?-wDsJtiJkt!@l@VEkS`WsY4Oy$O?f;T=B`wR z44s*1k8+RAJ(=g2?m#J6$F)(XHl{?E8;mC^>najVx2+P;BU^gQtv$|P$V7f7nNeb) zOZ7TyA=Qt@xZ2h%@D0tTx%Zfh5o8HhSdDzKmk-z7`d}$LNz6x|df(*tj1yNIhye0- zo*gUX?NLgWV-Q0bXW0i%29s1t8u?5}_Ahsj4K87q4y_6$)J3!(HqYbb-PqqhtJ&5p zl!&}sf+Sfim0(&AB3I$6_-W0YNG%HLKp7c}sY=JS{2MWU*VSS=-%U$JyuH7=6Mn9< zGQJ~sq^@hPD07|bczYP}Q*1tf(J9G8q93#fzLpKa{fs$c6(0&PhO}(mD>`+8C#Mr! z&*-x0u;1Y;hmrRCSHFv|PlT8=E0opA6w$-`Ew;lHr3m%xA7I}?^A-#P=X!03S#l?i;kh{-!)r?Z-cXeyC ztG~q4heWf1mQ6YSixT=Y5OjRCq{2ulq7 zQ&LYwY9&60>98#NGtno{{JSP!;Rg_+_ntLjR}$tPc&DS(fZ+^tLGSX)VXwPxDm!}-(<Ey!iI`4Q+g}UjT?s?cc(~D z?DyVZ&QiO1x1Y^Fuz^r%$-9&+^94KVNwRGkFf}zu+4y?O^TU*?Fjil!?x-cjQ*65I zI^`>sCw?BnAc$W}51LwUw5pKK(3GxAw12q6Sd%Vei4Z#Yi--|>E=6TvS)38vpG@Kr z?^ax`a~ZQc`fk78olGj}TipLU)zoh|CN^FtJ}L)er?vVS!=iCb@^M#}m{b;$f~2zk zBY2bD;ql4atBUlqH$kW&Ju^T3%`HkLZYJ1YsE&^S&Q(E$C9Y zCQlJMLOvJANE40B3N&v=a3-L~%ZXB+h(u^g6VNNXn${LjYZ6Pre!a8QcVZm%bY4R% zHFO+3waz6g-QEKTIge5WJsYcA^hhK0aDk|ozptE2g62JD(WjYFmwHre91DTxM?U)P zD`VR0My7oK(4(1ZInr)a!Wa1qzp5)*m*rAAo^@qvo9kRpYPxdR60k}BO8Mv3-$L|i zH{UikRI+hMQ5FrlJaM;?X84P~f#2L)qW41Ryt3{}z*HeMU^a6X()JO+K>b{m_}R_#ci{dz|P{L-r@KC%zC4r*p$nZlOD-x$Gh#J)i$f_r?Rxh(>?o6 znm(D-tUa*d7Csl7q=}N^Do>cVtIA)z?8b^6((4%qgq8VTmk{YX>^c56`LF-^ob-!d zuj)U1934s`=^tX@I=x!LAe2nIUVJ=H;b_FZSNm0!yo}*75Cit!vh+T9T9H@#8Pr(5 z{)Ck8o{e34)sORidvxn>=_d!niQ>|Ky_&>C(dF+D9naqtmqMHW*_JX1nkSWf@>JV) z>-5v*6sJ@y1NeROmHQm^1xl)e1DF$5_N#AY>H4ja*iwgnV(fVep_7q)Hv4V}XVa7^Wa|u)T!ybWsf%86(8}o0v{nF)_e+{VqXTAEfMIYWe>ArX?O;Tw5lj!&G=8z;I_oL}C zp=)Gz!aQ3}8@pSZo=Kkip-GAaSvfgl;H7at=KItD?GnK`k!?}26EKymd$#E;6|n^7 zk7DB&aZa8_Mt*TG#;lImE&Tl?t^c#Xzkj|HnuwS<3h&~nOhQ>{nO?0M&#K8_bm8j^ zRCKbI2RjQ3-yLbra1kMq&KLm{prq(BunoyjpapmB``mxz6Jh(DmEusWdN;ii*-rQ? znuL_}ffWsXYHEcb4}EfBp%N3hH!1-{4LP1%QjXInhy8O#cpo3tkdKY#X;nD_6mP-5 zcy0gwgK}G2!(iGw$jkvVNdh&ah7@TCYq_=2i)YHf9YaE6VPw>guJR0JJP|qAS%cyn zXZ?2H1wv}ChoRvKX{{wNo&sOIYb6MYp}jP9n8r(PQYf^>yeQ!Nmg`?clkQKass5X5elNMA6rb;+`AhDd0xL z!2zz5RFO=FHhOMyUbqxTV}kg*_gVHBGkKrbJ zSod~Xe{7#s_RL-y`baxH_1oI+#SN(AGWS5qUl zrw%+dm>uZ|jTD(wmIm#CB;n!(ujkBw`eIxL zcw{W3fL6)9d{mOPqpNElg8^eCCgj13VX)zEJbxEeS`^2ml{y>dhPwNb%gx;Ac8j{EfMx5tI>NC&ZyiNl)${8yCkFFQW0{#n+`JN7he_wQx^fTHM?R0&3{CP}nacv;Bfs{{ z_1!z)LL1atHyr#xPLfuonOajMJM_*UzW}_`wNQX@+Z!+)lX&`6f!6+XV`Br@uve}C zD<`i^-N;y)1CTkOGxq5hV_nkg>$|((5!6I{`68Frt1;#LdN-O!mWU3tCEI&D?X6wO zlew&102lngwdATH(le85+iq#m;I}s6(W2mt{|0_NUE85g_^mt~8hk;7ws-FY%qRgs zgMM0QWm!!lUl;gWOvqGbmNAjxx)PgN|4dfEk|bLmS`s~G4!?Eq>Kv|<0b=$loxxP} zw`KzR2|Yc4-c|)HoHq^^1N{BBcXqNO(d0LRX_kvNDi=?1Q;+MWniqG5@^l35Jh(?M zu7A$R9XgjbY9Q(}#;79gVWtV=JxBE>ccgIU5V$0&dh(JGUb-Kiq_(4<{D@ohn@& z>`dg%Kwl$@6D}>ONG(Zy0JJ*PA|t4IaR^1QeNZy zh0I7KB{emSDlY&10fCWKS2Nq2>wdu;4xuWpdve_sHjH|@(a#s2P*Ct!nT|GsE7R2= zLI}~mhx`TXqX|A%VRArG4qaTrl-MJYDAt5C8n33G3Y^wIn^W4BJ41(S4Yget*FVf+FxT9JX-$M^MDY<9sl|ZV zUnibKgdF8tM-PeHYvk$DN_bR1VKOY#^qOs&U)*~v3j$HFx#EnP6*5zmau>Uv2y(-8 z;Aoi?gV)=K4^6-wB*9L~A-M-;Nq)JN^@?$Qpwl` zdTS72jl0|BD|p{U-MZofb2MfQ73E_^KUu%YoFzCHZwlT^slAt&mq|JOA}ndbyV z)3!p87IwXuODLEz(OUtCuJLQ^^_J_gL1;TZ?_S>0F{T-ExA zP>H-8hG!apm{>4ZTU|-%TH%`>#d52+lX42ep!8i``UAq0tQV{gbhau)SMarpJDO!v zzqu2|O$-cVy}l?(5Z=gy?1<%yGp(P!x!DwWOGjH-*rekSviINTVW==Qn$k}ZKT zyj*D+o|sMn2reN=;vMSfu0WQxh=ZC(oS?W2F334(bvkhC6F2x-`Tm49S zdyKMiF$S{kHOBAkZEaCv zhpKh#Z-O_`9rfJ>8r!J~4A)2Th5bGz-iHBWX}5W0l@HVY+|$Bb4nC1vut9oLKyA*w z16JfA0q}t9CM;17VYJ>I!u#z89H4Jo0&k-#GmatSqUhRaeq7^7amVV=oUMzB$_}7=AlJA=4<{9XF@N1kWrV9)UCq)9ef7Z?22&#= zHz^QpGWW{L3X?a;EzQlNqNBOB>v$%;phksI)O%d)j7ad9TNbo;+}{U(XTL2Q*vmkp z1YhnXRavrSpj`tbqm+c+FIZR9vuHEr{iDjAw6wH9MWmg(v9y$#oy~v*DCT}lAUw=K z(w`#YL~BB~*I-z9a&iL9DWJ!}AwrV8^2pPx1Wv$7$R8OPh$Nv9a2PF#nVyc~)Th~A zT)zf1Qo7v;C3xBuSxpkg@mr4WE(2{wd~VztPO-Byc0OkU z# zgd$GJ?r8VNVj}g1aBT!DQEZdS~OJWv_(HZoSZmKdLU*4xDq9tIF~zO zfZmi*Ri%Tnw6k73TTME5Yq?<0evj2{CuoSBo&8PEGZ6Aj@msvf%ts%q%h~oyOdT~# zm7NnnL0)G$yt$e5&!~Cy)juP7eTu$5Vp>5Gsv9>7-@Ve&IbKE{oSz&|!tWhe*rjL$BJ>Ia#G6=F&#ac0Gia*8~dPBkQrr zN%1+4Jtn{nE6uLgxW3}149U)#QjkTAy^F%WFSjW6eX&Aa?c79Cezjs``~33+{`Egz zba2xXUcD-Dw#29*P=4{+wf-vW>K`tXAAB$U{D}|T$Oe0;YOVGS=DP(rxrj;-z?#;=ZpMT>vHXq4?^APHj4I7hV~u2aZJ1|ll-bW_ZH;F6a*vOj8yrOs z*1|&!#t+(h7-ieqxVol|+kB|tkjo$3%Bd<&u{#>5Z)80eH~ z5`4Pb`t;D{(Cg77pf$(`UG3uJRLs}5G%|Xe>n6^}CnWUrpNRs8-u@hcx%sVjC-=&} zfyUCxN+B^04!3;^Vns!HE-BWcp_uINnI|Ub$?c;1KtW@CfC4!oFqve zdo_h0r32ss0uksibU9@a(=B0)RaHOG6E&-%wy7JPp2Kz4)4x~}UG}wzGBPd#Ek+Rt z>Tn~--4UVVdNYU(Vh;~IhLF}N{L9}pN4kRagcRr%>~ zgg3)!ef)4=1RWh6YnFdIytT}8yYTT$UsGmSQdCq@Qk_{Rm57LlIb_>kxnZP_>L;|2k&)KY)+!uX!|{CU;&QXJ zprpjUKU0a}*)z^4USQ>a_;A7OO$mipy^*vkc@iWF^4#Pzvv+a1OP~6Gp_I2iY|l#X z_%C|zslLPsw{)Jz!p&Az6ch~YiP~K+L#bn8YSaZiH)!HI^=WUuf5`)d%=wz8oPw`Xl_Ei^@! zr5PkV&zE^km>Zg5)2&?${`s9KW)PXR1AO;f{;BDz&fryAp^xl}o zsg>JhrS9Fd7nv`Rg(r?N2T}2k4h^ACIdoEc>;Kc7W@)$}wL%jpj?)O8%qX@FA7^e}dhDf8Zk4jE5{Z+{v@*k~v@K6$F2Xq${aKVjv^b;RR0uiV}I}6K`1v$mx zB)`CEf}vCbw;yFzdh`f|ZGq}%ug2VX=?o1pod}it{_Zl&sFa_P%Ng4$7nPTXczFR* zRR112Xt^4gnGKGN?6sH|6c+%%W$laon{^EH! zKw4T_#avAuWVS_vA#}5Pdh!#;X5I5r`(LHoV&ihMvoqwAddw>mYJQgnva?0{Q}u9g zvO&=pj15rl=!Pw=yEFZ^ilXXF+NC9V84N7Z>M)lDNHGb8d4fzGNsMxr`7# z-K%_EtO<;aNfZMX#d5a54oiulZUmC(S%GI=iq!P{`o!dIB5Uunw(a4 zjSb1?3TX5X3FtF*e&GpBtZb{u{3N=lX7E6|=Jyy$0xZO)ru6YK7`hNoU?%;d1&cOQYYR)(JQaLqfHFQ*d^K!o|)C4Zl5wQr+UAwdTf~94F0}AAPSSk=x z1y{)H3-RIYC9OH2!@T7+;l3FNNypmSTEK%MldRAM&<6sU`0n426?N%t+`V#)S&zF; z%NgDZw%T>@mHC8pbCsjWDXM-{f-aB9rw{$dQANdWElUM%P*CH_hR|H1q5y`k0s-)? zutelyr+4tA=j7lJONi#SKDz+c=jci@|Hl?Y$J#R94_JLsiz=Qsu$AQj??ZTM@jsev zx=Br)r0~2tI%#F$K0aSi%(pvk7_#Asi`ZPQ3;50f%xr z5=v)b9-CEsI@US!GtM-ptnSidw&XG<2AL<4A~);0Or>mfQp|k(L-`%iIs>QSVtI@yr$|L#QRr znop1WdV4dkslN8wN@2Nyp(^7O5O~cKBT_fqlcg$$+uRwV`H&;ZS#B`bfDQrXPh>0| z936}|I4>s(+I+P${Xh6ydk;}}u*>c%-@nKiPDorxnbp-e{!XlNI7K-Ng0uH01g0!b zmlzqvtUULs4s!!6%*;+OgSl}A?m@1T-zBpGf!b7&xwKbzu2-x8Rc&Q|ztiNz_7hjE zsq_KauVOcxKRr1)Ip%NPfZ!8a)RGcN(iIjKI!(T?j((A_x37c?o6!v^9|QcTfS26r&p(oV2L=u`z^+^TES%IaSHDyx>*blETEK^xJ@ zl`STq!siFxul2?{9_L7$#ZKI&2~-b%Y;pxNSYHGpubDiVX{*33C#2C?BHD+*E?6sE zn@F+C_3WZ?76Q=6wyAG*^I41(n)gro5;7QNMncVZNSqG73jl0ilK77q z!=^e!0BoAMR-iHFbPAZQEg>PNq@=mp$dCupeKqskXdTytIPX+eYTxi*Ue43ke?rWr zZ2)om8{VVas?j_LZ#irE*KNAh;|DR~P#;B;dSE4Zt+B&`C#&_KJxj3-W0SwprE+>O zA<&caT2sd}cj?9hXHentIEUf_iVdmRHw5jvzdw7|@oqtw}x&3YDy+q7YM9be&7cgMB4^jr) zNFCqEQhi*SPGA4RZ=cn+T~p_wXPql<UJm@72+pt<(edbtSd@doN(8ij}abih~8EGVUi?}Q$Aej)bHEJJMoN(Nk45tL5eN*E`5Fv}q zg8cBNE~9m}yBn9-tJ>F>Q{?BQg|W@&LpQ%>CyzG2@~t3%;8%3Hq@|_5qCyIGT{0D4 z4qa-kSKgkPKD1N)Ih{wmW^EnwL^NorzL-ls3+{lvhe6bnx3SdIz}=&_a|5RcSK6f| ziR!oL(;b)24(M@-EpO0W2%&yKQUQ)Xk`JE}-G(@myL-yfKmj`ubHKJ~0HFQQ<;>mF zkF^>cD2(E~*5z|6=6Hz`;}~0lE0OHrpV)rnmt{s)(U+hxFI!;&s)so_^QE zwHXQa_u8b7DLV5rrdU7i!RBHb-jUmTZKLXv#fD-gm2>fpDJs@0{IEYf_dk+0B-X-Mv@SQxA%`mydqUa`BIz*i4P zJBPAYvqx_LIbt?KEu%E^lOp&YuNql>QDcq=aFv`jH-o!C4w??NgKWTDVt<;;*(Fcr ztjSA0>R;1%H7?A*oL_nsYDf7*NsZ(iICn+fFf5trLE@94(fOsXK2K#9DjWH_UV`Aw1r~rNo2n2== zf!{~Urd^;+);s~bXoWqa9{gy-wVLqhN;%v<&iZT*^tTVOv$q#iG+NW~d`M&r@hzr_ z-68egnMlim(+XX;$dghclWjmoF}YWcnr zOdgaN02@lg4B1%#C2XvpKU0rPPqTxozZq@pz!6k;HZMzv5ej>@w2h^6XDdH(wjIs~ znOWYOTR*c}C(&XH$`~uQs4!m>3;Q@pi*B3E&rg-L71Vtwo2l5~o{(f(fzr_7oblLh zMH_W|-EDqa>592>pixD}Dc?cEC#3=GxF{!NhmBz(}8qXuj- zcHz<&3wze=XZMIJuOzy-LUm-Mf4Y7^L$1Kg?+eR{ODyS1*{w3vvhUhEevy#*X^Yh> z8$R*f2dOQFMyc6l{$HZyty(tk3#d0uw|MdZ0N2!9-m1Uh=!^;6%=3Yf-}2sOalfkF zkI?a?0ot&dB71`XZ{Em1Ap{d*&M4{8Ge6q_O>LTe2MSrK=co4-XDJe&3XLX`0tVVb zKbmmm)kHdsA7-CP-p@#Z+NNs5TWY7sOI|aCY>p#B{QLJWCnWCp4^4$ns3s3!bmYVM zIy(yz_P13)Z%?uB;3)hFbgO*7BLhiUO;spK47XptN@D?(lL8 z%}E|={^1+s;<7(UjyAv6o?vha0PqugtIqd0#YhCc{I_cf`A&fcdI0pB{IRE**_t2+K2-dFjc`1h|v_jW9lluWm)g17eZ`d_IT5bZj~aVfHV;qQsIE(l!AT^iDH^3jiISYI2 z=VEE|44&*WPm8^@}dMIrbx%vrxuVk}7>{IE= z7#Sy6xd}~9KM(@0kF8fFm&?r9qvdF^g7^xbmbr5Zz&u)ADcy5nE+P7{p0!c0f75&D zOpz+yd#gTJJ2lljTS`KF*z51#0P}!L?Hi%e(gM?RMr%Q9S-B2yuOY93t)$W>3vFum z$MQ0tF%uJ$F`sy{59uf8G`=}(#btdOmb1~;_` zhb^{{7VJkVB*d{(>HWwE#^}kTM}ykbq$iQ_@L%!OZ|>%9>@17HF)xKpA6vf%k2%+a z5pO$rVcL*~!ueU&#cBn?{iQ6p(f9s`XE>`XGivi4KWB<;`nvBQjT^#Jgy~CJso8Tk zG$lBf+-ev!Qj0pHWU+dSgXYvK-IKg5Lo53@U-0 zi0c>sb({0y)Bgh|{6GCCe9lpyKX+#%@#I7fD6Dt@*cCVZR9bix-{N^|wllp1s%iH1blXs@79<$9|jy9?b61BEU6&qd%y zEj3MOMCgvi?f7%?WGd1# zS*o<3OSBim-Xy)7h3Ja}=BdEaL)XOee+I!I?tS(m_R6|tN zX=tzWfd6h06VqCs^#=%rXg?vN$rBjx+Q_o*)l5tx(Uy>BWgh~{pzK764uK7xX~8=q z<$~DqN@08#^JFZNdPzH3y4uVnLLYDFi?QN1rV#VliIaf0C%&KOAoukM!lS=4s$z^(p(xuF_*W?{;o|f*3Oz)@?R%V2(BW6&&<#s#E_8fz$_#1C){zS zQ16h!8L==vUKxjLf!+m)HnnR($E0(`XO*hlX9`O&KYx_#M-onmB5h3eUvzl*AL!H> zwwPzp5;D!K;nkndFlXzUVSdZ1F$Vi!@5!HbSF|NlK!S)JE6Ik}*k99|WvM2~BDUgp zHL~V%3=;;Ch1xwlk5`@|iLz+Jpylh(VB#Im@5kzzsXi#fzPaU^mGl#*0P_VMoCC2b zweKp85Gz@@pf0e`!Ozmz#QMP#%1yP%Xi+O{8|G^a+rMMg3J?C-=)JOc^2s^$t(Xaq znW|X@4V5BO#sb!f|BuLFav#glTQ+Q|_m$PvP`2p$-8HrC9Pt=e!=ONOM{mWHWGr=K zx25*!tY5VP|K&f^GqIKHEu~JuwjWzwZ+yP2X@8J0vGidI)gz-fn8`d8+#S&_nQknA zpfqe@hQzHhAqz97m#hyZr`xVlf-z2qs&Wnt@7K1|DINtn2ei5_#NmsVW8ZB=f?jl< z#Ow_}Myal^lU~ZkAG?Hm_10&+SIAww4cedK7@SQH+e>GDhQdLZJhx zRYbkj&1f<@d{D2Vl(HlL8=yTQR^SECml?wuVY`OX?V5>X;!T|lMK7n8wi6M1>bL4y zgHv}T9enhOzuED~rK=oQgo`m`3wEQXi@r`?@G_!I><%GC?R2`UXS1L#hlv^A$`Px} zZVn;{ma`tD@#h99{q95j!XFuvf3QhE#M_*c_rDt4eOPMM8W|IYMh1`_o%#r~cbsQF zJOOR~2%ayTmeJ#iYCs~Yu2YIHbTv1CQ&NefcZ}lKPuCPTI%G!Nkwg+dK0Q2VSw>GP zkiN!`g9-Y&5)Yb&XZD&_hH6zMTagb@I({a`H6;<;>NPO?3^2|YgI^8DdK56dgBFLx ze$6^58`^GGaMQ)m;o788Fe0em4L3(*kE2iw0$Q!bYCzRrMqYk+;PEU|tf7CvNI@E= z@S20Z1cv}nK%Pn=|Gg1(;K67%>nBzsHZ>PW&4#-^)*L`Y?s*x-9qs@gP;YjgjnK^c+tbPILxFJhp>ekf zZ&Y$J`21G_E5vK9sg)8?csOrHX+3J+zMyI=l8qqomzdvN9H#oEy^gnVcqaskiu@?W z`}LKXcaHB)nQKEc$F1n*+#PNE)7aKNlWRDAlUz#SF_%-2SI7>9vB=2Sb=DJ5PtsUbhdBZ|$fvx=u@$3QMX_WHXV%dY%L z+~ZzdeZ{7R+*fs?Zyp7B%_I@gnz?Qn@vS5MUPt&X*UK^CdTijF~KYWI0vku{uL1967(foMAv&~hR?4i z_=a(I;rdTIkGnNi=8sXMacn3kwU``8a$EcA>ufG1ZEtf?mzMqDQ?_|^u`e&kT*fNn zLxHO>qa}c&)rc3IH1?_f!aES!Xjr|vUsY5gz-ta=C~2>_*Bc|>QixxmZzFHJ4bM7I zk+}1%DA>_!>d~>Ltcy_`?o1Sf{x|WJm6c*$2g|cD8Dj@`Pq-)UkjWx2PD0w$sB7w9 zr`^h`$3SbdK*W+ueWfyBigjtL4dUjoPfbMgPcICf;L!Rb-PA(}1ze%>V zLHU?@d*Ar;qC~R8b8S@dYiH7xV6+hjbg<00UK}(m^(vEbm*LcfL6Bjl>EzO?T|B|L zO(pFq*r=Rc9ROQEp;5d2TU6!!j{&6{gA+ade4Y94)DZg5 z8hL^J$P%dDLyB)7>Cis`?Z5gKdg{C=-TS3#Vx8HT=G@Lv7yq}`z*Xhdy`RB@qC|U+ z7K_#N0eN}ctZ!F`5(f>2y!;TUE)&Cql~mP(@78M7Z0;`CTTVM0W~;jUH)*_5}ii91yFGsurc(Vt2Q678jl<5{2%}ql83l=Bg|Th9EpQ$VaB> za0YAfK9^W&i1&6;7DjuOb$Dr63A`3^P^8*sBG9|LDm&zkRNzo^M{b4xoeF)Wp}W4FP{NWBbyt*VYroDM{DzB!Q`Ie|6M zu9l?~{{>7Bt1NoK5Wlp_bb@}@+L>wNUS(1;L1)pn$XHeNa#A=j0q=Om*Jt!x0&{=+ z3Haxi3%WiXNRa>*b~CqMW38G^JB;|c*QKM?Sc(tb2V1OOM~UlzOR~2#U+gG2w6ceQ zPZK0pcqn%i$$y=}Eir$HpWtANdw|_sGxKKc3p_>0dYlYGMmx}JsW##Sy9V9`^n6BZ z;1^-k?uYIVPoP_F1;V%0LxZJ*HafyCZ;w+g zs+ucr?{DXG`vt3(#Uc4UhX&lFezBkb$);NV&Y?8^S5o!=6@dPQ`sN+FJ{brHS4M6- z?MzprMK{ELkz9_a{$qmwAwqWNBrA901@CWww_N=UtMv|%-`So23F0|^3gRs*aX@n*zQv1`1?6qGG}oGJ*;G9RT{7+ynRfY^!68OD$nfsgRuHLNRoP67Uzp4cy!CR!!pR?|%++Xy!&Kc=roK}Ea#yVxu5PHzO zKa(tR`t`NA5kakDt4Ca4_l6@Q668iieGHH;JAAsiD2h+g;$mHJpbCPK$IGiN4m37F zF*d6}TDP~m>OzAy>v?u4cSlYfa;O_X@UNs=EE~Ttq%=~@!WLNtNg);%QT409wjB5O z>Rr1D8f!EwF@@2kvsYS?w)V{Z zBLB8K>?WI$wIB5p+>k$YAC`tV1+dRicW3HnM24M%r!_e8;8<8Ado-6_8F?Cc(fld0UX7Dp$Pljv?w>5EcbhHlI+>dA z1*K)&%2YL~dZ9|;?0);i?Gr_cpY1kePuX-+R1r z9(}alzoS%f)z8f6=*`@B8wgk2&GR0n8Ve&Gpo(=L3aKky!u)B5SvptK36bF2=LHzP zIkQZ0`rR1UE%>@x+h+I`P+J~l-fgD_f+7v{O4Nu3zuxW{yriImE%COb51YO^q?P~L zMWq8Lszr`T^>RY#E-rU4`k;Lw=MI@^v0(*DpK;<@JfdozRooD9%xVkbMUjH*Ww^9 zrLTsn2^)f3qiSXf=yHiwz-l-(5+CYJEy0v|^J3KfoZ2JOG_TfO)zLzW0@Ur$@t?S8 z8}7hOmPdLxUo-3jf0l;lo)fegKa1D|8+~F46FF|~MJQkU2CsC%`9Q{iN;3AV^fYnp z-7MrXB$IfvggR&zqho*h@j0;i+LTQ_5dV6!!NDlQQX|wmFehY5z#wi8G4;ShPtB?Q zZ)gGj4MsgHKXX6k$!S}k2lljv1j(IA;bS=p7l)MkjCh(FEZSIQ8Zt4JfQ#tGYnVj_ znA7D0rO&Nk94^nSA`(nFr+NAvz$Ns9>k4d$vW>YWR>eE}+@5hzk4nk3k?n_h=}DRlCsV-#W?{rF;u@+Ep?fOzKO$w#_MdV zQCDAE+c!wfP{3oZD7$pOjM_{Uj{LRS9|ZQjia3T6Z3L)-k$k#4nbR?Y*q|ll8KyKX zbXSGS28E^jcZ8I zF}51L>H_HIOysdbE7ktdfXMG^zDLV-SvT=_eA@qBEE(DSXx|nxDHOGJXpd#t=)5m< zBi_UJ+AF!tzwK5=Yt?zboXDu)_iZHA*K{t~l&5`R#UOy``N<(#!GVEV72bYa|A7!2 z@^ubuO9*j7%dny0naI>x4)DoRcH92$0X|~^8@!dx+S7ydfm(KZ<9D8+T1>l5+je+D zQvI(007uy&=P#aqxTWkrHzf`4pSqCSCl68HSj*7(<{4b zwLv9dYvWpgS_d7!#hn#tfo{uzWFMhlj1ML_j0YqX9Ia?_q{0CF?_%GM*9tAw7DfzK zO4X;j=8=aJEwF^QK3F*jhrUK${&0PrMEFLZbGzm3A*_0{_rt5Kr#|>pB`JEiN-imZYzcuX&dp zviQiCN@1b?Ea_|iOLB6Sd`D#@(0yao?v(wP-7EuE>mTu=$JV8ox>Yz4n~cz49$WC2 z$(Kyb|BUBH`g?ZGh=ncD5>|&9vpM0&2{MALzq!8QhO5R91L2D{$jg#??|aLFvxrr0 z)*K(O|D-n|qb*=_WqfFE3qImGeLd`>ENyn_jFPquvUFnp`$6jn#Q%O45ZK7CtQ{bZ zz|~GIR_Kv3mo`&VVZ((XsFk5q5*l7INsTTGER)%4{)Y0xKRbGW$4ZVDvm~Z`dgkN& zCk)pD{Xs{0_hfE5Kw$##G{Q=T`vf+%2?nE0(f8SCr?7L7^aRsI2H?-6R z*@XILRf1Qb6%`QZXoZn>ZNSVq_F-P&v_-AQGc}K)*lx^edu;i=#l%$mtGgzx_ux>h zAALR>WO49_anR6t*#ZtX!KZ_O$w9lO(xzZJ$i>dnB(m)>D`mEPYFxdw_jHWMbFi(6 ziTGW4QGY9a^G!b8hZwG}em&x7BlFg5SRDj$YqHv-8d0e35>ytDYFJS_*X$Ft4hwzk zHFrdo;hn9ZY$HriMZG*av2v^Smi3^P36Q=2v1+IgCt)$Z(0+}pUs)8T?busA;apc8 z?@25HwTQb%MlYg4*Hk@rbadCn1cpKEz(Hq)*fcgw@t_kA^-$IcomYsTUa+Xjd1K%4 zwd`~)V~JN(Rvy>}=crWEJct*a2A%#pUVx)slvpOSuPhYS^A)0Xw3t~JwLRAr;}nSm zR{GEK>B{aLmquYwW|i9G5W?mh!b?e)>uc?cl@cX_{ehX{{*RS{tZDMnh6ZP*T@iSY zVGH*2F2P!u8f-V~t$JxR*x34L!J#0uGGN)l`72@<+u{)7!?A$pvYFc%$A&@9Wr&s+ z!!+(H@sU!Ie=p06%30$Z?a()i#J<&Ue%ZyPvRKDfcJW+Q5eA~f?aJX@7ungtHFwoD zwMxix>b^}wf7%{8oZK}EGgqL!==y};6`JG`qu2TwhU&O>(EK*dD+oUt?MhBn@x2^w z$qG24@XN{%56|M?ZywsV7Yz^Q6U>g6Q!<)85eH4ygL<#2uuTI(+30?CwTumzTkP7z zH+NEZKHpeFty{HoBzt&jW~s7_L$kU+&9ZD+3afT#m=o-rMBqa43Y@B*vEAq4`~q2f z{?7Q{Y>ju5S4}9vXqQKLIbZ{(3U~Cvobjo_z4cH(?ql2X*b@~z!G}B~$=gI7r22#9 zDj4kdvy_K&8+AL=m)zs&rXhRptb6JjeAT`(8d9G>=e;ePVSTt^Wz&ndnN$g@l&afg z7X@y9sHhm?G8NTXYJgqDMa<+9E6i;Dck@!cnoE`^Ekumy7!DE~cdB?atm#1--*qQw ze;dh>7joF7c_p@o@06pXvhD_u>SV~-THa)FaaCBEux{bbK-d0Ox#_`3a{qY6%LIn{ zauLY==2(MmfaA1XAN!$X`)>e5r|pR$R>K;pA2al@9=)NnB@lEh09ygFgN(WQd$2Bl ztnASyet1NlS8IhQ(uOgj0p8%U(nNXM{tplTVj|zI6ehd}6YoyAUSLLo?P}B9F{}Ms z2)&B>$Z;=N|2|q|tOfp_V^ZY|I_k%k4!D)IqJ*`0idapI=IczC4$a=XH{*t5t>-&% zzZNCHy@!a!2~CduLl_@A6W1=huG;i+ChSg4^sTmi{gm*HM2xU6*$~~l@qNDnt?dfm zXCdXRKS61AIBryv>*T80i9!=bk42GHq`5q>JS#BZJvWDtr-31!d1Lz${d$7=;m?d2 z%QQVf^O2SajYt<^ljb3zW!TN@T7@ zqYDMb8eWC-nNM2VR^@p=VnnyB!`e!=HYt;(nOEhGn;%$OwP1b(Wl@TQ>7~}1%y#o{ z3|8VWjzu3)VqR8uHHwo1f&B1p5`l64$dX-$bGn#8)wq_N8%%Qg?8=KLli~Gtmz==( zJ#wZXh^ek!bHn%Nm!Vs+hIX7X%h%f~yJR`wZhd*$W%k5knJPDad!#JtTn6M3u|XAj zOqZr8gHVb-B#0m`#4lu^ns1~vh!iR+yl5b~!t`I0!J|RBDxP+uUvnnM2V(w0mn)ZD z_th3l6LbesLrsv}{X`;dzj4dPg{2M~YNmK|`jS}EzkgwZNu5@MV@B&!@-oU{L2@9C zV!w{+9KNx!#SC)=8B64OH|`pt4d=Cc)I{F1HSwAna^2}7sdkLfYM}4*Ru7K{uGJ~A zcBbl7-?ijqSu2Mtx*hKV*{{dyW=FZ*BWJ9d@E{M%qpf%ALe9!s%R{;T*{2PYu2dYO zDwk|_t01kX&SVQ|M2k;?U(KxokB;>u+p8KOg|E`r(c6yqrPHw(^D?UycXUpjweLFCfX|N4`6u5fR#ya~s!tKFu zQH=XWR~0A!N6P-?f~FS-bggva*dd3p>!*3#(#}`q&T@m8VCE++-odaV}MzDcX# zsbFk1uQt8;#WdC*EuyPvoWgMDIPI*%g%oSzA2B_;g=m@~XO#{5bh)Z5wf?*`Vem9b!l_Dw6C?~Z4+O`ns2!a< zl>Gd>2tE`4!xwcog&`s{-m0!8h5O$x?ewTec?&m$@tHV@i98|r>K{L<(^D&DbQZx1 z5Y|d}@neO3d3gdrOQ0z?LYe9NU%j! zI6N4;F8SEK{1F(qPV~$2F5Br4Dr#WonJ-ojbCRkX<5ERjVxDc4q?7_ejGU&pI@CB$ z#V(Osk4rK+uIK~XF9h#LYOp<`L^=4+q3Gv^4zIWAR0tDi_QQt#L9xRX4{s6@!co;c zbzK%@_oOgued2~; zsocQ6I3y_JXs9tT(g6o<(1g%@`=f`jkk(>5j&qjEI4oe(4-Ci}{-vV24^zBYBDqCb z<~lIFep>WLJI&`6qS}m)l>JUl2_h|aA}XBkY=M@lObV+t#aLRpl4nxLYXPnXeF| za&1Nnx(YN8wZ6(jX;a|cTIrd*wi}RDpnLqKq(mVf=n1WP7QDe_)jqmk|oL+dtXM zVYU>XmlqKt=~kPOQe{MqX~&R(@pqPs*dNE$uiUwc)@-m{DcKf~=9jaDV|FLb130_; zS30xYo}B3On3%3u^TJ7=>oPeedQwvFXgv|lWbTgf>9(Fq+u^$?);%`1o?OnV_AP)9 zYNt7Tfz26DD76(0CwN$L{HLtX_p~$MSd?md?Pfa$^%_y#*w*W=gAJ(r~#FWpDDMOw}#U7$cM#M!Y ztq=6eiby076D`M4JhYI*xMlX-Ou(#Nd)vdNJ^vyQ!qQ`!zmAY~SriTSAY}@ltpr!Q z?v_yuV-l>MdyM8JwG@~;oIn*lxDoalR(GZB1CW|JHQ+Vt`rLK~wNgX!P#AgSS0<{< z72}epO+}P-R_LO(CXYtgHL3d$8Mocy-2Gb=M1OFyXqvy zC7}pDBaNmtqyrn@iiM7BXwG-jICP-fG%9&5aMcs3Vf3rKiPJv%yOB9vkVRO>n2OMc z-|59`>~tz6&_ck=y8@fdONi|;tbg0YcI+T6yPCdmSBqY~wU8%}z;ZFMTK_r>YWbTO zMGt_#&a*u5F5?wYJwB{R4b$U+1S2X^V7grDu9;0gr@a&K0_&?w$hrER>P~o&Lwsu` zCVka`hV};+Cc~cg#eg7Jb-8+=BJjPe2}h`+z}ZmMphb)|yN}y0{;01SJIimF8nka*U zJA1=fEvd|AQoQNkHkj5@&t=mcx=6((%v4Cvqkfk}_h0g*&T4!kFm3T^YyIaiPyCKv z&FWa$v}D&GZ%Mktex{c)yz^Z>?HH-!1+3>v#{SS%`zRT|MzpW{V7FHwY%5rMAK%-b zAusE&d(^;a@i$4OmLt1E_cNM@n?2Km77qHz4W&Q#QjdP7I`P{#ZPwdov}9KXGD?$n z>^@s;`NozBYk0~dZd`#_5WZ67N?VT)G6u$wpeD*fP>Y`q5?9Sl zToci2=XGRiDxj{BwdcEoP6uDtv3Ev(%Vxcan|%P*uz2u0pb{ecSA=NR7^pypZEU_u z8mEXnAD3d>Pzef=Md<2V?4JX)Yb%{NucL99jPp|aGxsl#`-k%-hvZDg^RiMDmFQqh zFz0mMS7QFD-~5+oMdR>)|7F_h{@KevqWrJXAo`Cc|D(zO|7kKSln%SjQ8B>v@5G>w zgEu%e(tE!eLM-`Sa0ajZ>sRx}$=^8d|7ZB0GxoSQ3e4aK$t@k2ZmNe6ofEG zK;|LHC`m{Z8jxA$F$wb&LVyrLNFeWcf4Ba1|F7!StGe%2y{cQCs^kp&d3#2?v6_>nv`sa@y?*9_`NAcaq*H-^1x8=SH zxqi#jI$P7uvwiAV3b7?2m!RB#(%k9d?LUh@{8IMz?7?KQLrqu2^?o~b^TS+}HPzTe ze9qMO&+F`9<>5qFj}g1E_vib(QFfbjExhE%up;{rD?wJkj)2k8gQo-pp8hWU0656~ z^(ipgzn%*m28Ma`U}{2M;t@mD&r8omRB%(zmD ztEU0z>`K+auABDu_BJlWfH9aAik&fXTn@<>nJnsiA4{{ewShP37=hWJK$ ztSZl?b~2Dgj=hBeajCP)>Tl4?#kAqkLCN^ z3e7iBSHxECyrNxybLws?Y)&Pq-K{%@lVFkc>4RHy#XWUwCA%nKmN>{-nypA`$Mqjd zDzw0m%4!UfztP5hMn~3g`Jt3|FYZp5XJ@I=#;*C)+1bUoWo(1FVEw>3^s9tPPgY6` zX`l7{n)!&|1?*(<(RQsvEqm7|5p7S8#};55FBdF^o*asPs~xnM#p0i*FY`KbZsDdz>>JWwH74fcf4qUXL5v7Q&B5b~VC} z`(MWjxn%0iJ%}vOx=jpUN)lZ>?75Y()%5;ZyLi(!XZCtLs)DdqRPM%(m{PQft?`;% zD08|V$rjTz6Ft2?ni&gMG;NQ{&dV-80*YFFuHrhIuF$VG#@KJLsqp=4STrg27$VR| z806;W7KOtpY-b?G1U2O31B;$1p^dh>)vTbAjKRc1+7_Izp~tWLclXC$e-j=->l?5- znUJi*tVAXqYCL=lkAnno-LBs_nLx}7-Tc;Sg+BJsl)=a`vm7SGh}NT;s(m`=Ngog)vq9ds zQQ%M2!vWJ&)h2nN`$s2r%+r0)?*>T3Y^zwEX>o z7$^!n(OGKx?I?tSZI*-dbt{?AR@{d6J5 zSVk!Mg$>E(l!W|#?U#;l;+_pk4-pZ}a-CGV6eSz>-Zez&y? zxN49${jNAp$SCZZov^N8+nb3>9kumwWVG%}aT8Y6h60g8>lZg6uW(fD#TvH1y};5| zyiK;<^UlAPxsxeja#r!>TqXcJdM+{c zY>mp$=hn0(y~x#XN+KtcWz1c|LRxE<&*4v_-F!OCWrnR`o2RXWR8StPvSQs1b@_U~ zjsJ3n_U6`@pyKT_E~&>reK}Og=y;Ky}ia3d$qc_bMv zH~V~qqfl_Wf!1+c*TF&8;idTSC7;PVOX`8EUt)UGm<=n85=g4}*qP_wJ zW>})|8?~G6Kcn!fdUG5I>T)K+n#uSQ)1omKN*FP~1-YM-SC+&9Z#LzYgQBc$h>Zo) zi36IYo(RUfLX7eSLvQ)wP~$ZaXRm@DF?u&Bcs@tgeNk#bR`U#C>|nsyD^IAOb2iFG zS9ye(`3X9c6x>{NQ{?oDHXgC@G^cr2HcxQkSf)Yf?v5*ZUbA9w_=)pnufh1)H%8st z(8ODs1>V=s&c2}DxDb_|&lZ#4AdiW%GP+2W)XJatj#_>W%P{DR6A<`fc0&MG^6{2O zm-9f_o#L4p5mRAMS$N>)OpNJMW<>^X(i~P`9LXRHPRKL&9KfV2(;HOYeuI}-BpoKC zdc$^qa!@y4(bapR-tVz|!Uq*-t}5z;kKb{p4jXCdrcop8&mw8iY;3 zuDb-Fc~^yVcE!xK&mg9l8)oczRe~FsQ_*^k@>)fKXqPDriYhx0N>zs~ z|6L2Xd3l=d%*vr~-JGhLjZ5=$(V>$8*X){y8cco{1r7Q}x(&X)CYzWnGn^tHTt6ex zj(g}vT6HH;Bu#|#dnqA9GXyPZ#+opIvSO=JL&$V0YyWPa~f(rxZf05VwdAsVJ zf{ayK`9$p}H+t(P?lpn1s?d{bkz7H!ZzE+=qy%BcULzs5M?|!=etKB7Qvnq<_-b2e z`CA$WeJsas1kaM$jIc<(vilv8toJ(8V7Z{RVQBRtsvap4W07u9%mb6Fv1@X}rN0`dz5ChZ zx;oYzOKaVGu>DBEsEbo)cDs0I&7rz5LE1VK-39!=mb6;1p@C8lzLC+~Hs{RAlJCtj z+Aly#Ex3CEGp5@j&_Th*z+zAO`kL7{86&T8M6(eZll8N@dKG>P=ha`fSU??OnYQ@! zNQ(7jhEOdUk8Y|1pF|D@?>8y7U`Z;sKJWuM!Pn{8B?E>+00YpOFB1?H5D-6f;26M0 zfBES$aPE%y7vT5mpZ^0Ic&bUa^6$!TD-S7#nU&yn8CfR;1lC{v`jkV>+fBZyeG)@$ zoa$GD8DFij>uL1)JB#^-88g_wVcOO+8PZ>{^U{U{h3suW%E|ToyiCAEnT`PEe9W~m zRrN!y9isO(ewgb23L*GxGBb?39{RQL%nHya$^6N5^m(wY8m$NKQO5n1;ix^^r;H|3 zjDO74cD=|aJT%wE`LcJ<*IzBShtH#%D4_i^X^v;d>$6&!e5tefVPyOh|r!D z2|_?yBA>u`2}wp&cH3UU1X2evXd-)g&)r-O@$e=mfKmf%S1ssLP6i%au$*g>hsa}gk706KDhX_2LDZa?8?}D9mgr9 z_muHU427(s@_8%9sSsOn9P}hRm`3g`D^B;v zJghrkO6nI@<3e4#s_hwKy=bp0c5OIseJbybU>jNA_3EO6;^3#xPPudWBbF<%W#RNf zV`KxbOAaDu80p4mCP~UriVVI!`x}*%Fhwo!*1AL;F5yOZn+Ut|DJ32E937Js4qq{3 z&(!IovE<;DLLh&VKcw^}Q~hPd3+`N(^ZhCn=P~7|w~l%Nw>H0;Eg(t0maN3s6Nt62 zuqPRnkt>EtYlxb*$pBe+!hT1ty`1v^U1`>jaq{AV1<})`=Px*PIVA~GyVkyubSsXi z%{I&O8CIrJv}l#!duTt(a1Jz90!v?zsx3}yY*63&rt2kNUX`0mbQm!r_d=yika(ub zTHj6+MLQev`GM{7oAH`Sn5uLHA|VA7RbyQnGCdfLx>Ra0$3COJSwbiPMeX4sz?GDe zO~V`qWB^|koQydJTBX(ou022G6FTfsx6)r{6cy838_G#a=rMQEcD-M?CEkHz4zfq; zNqd;xEZE;sE1eLqj^{>}wtlr*mS(Zt54>l)kEU*F;n$Dbn$R0#658f3PMyOUt4PFM^S6 z6(jY?i?~1l#uI;1(=L~U-Z`7x(cLLJF;b2__EHPI{ri)QcQ%8C?0mzBP+F$s3EgXm zSE|Y#M*w1$gL??`3bu2l%2cd|h0*Qo?PA^R*0^0~wYBqbxJ@@?WnJ_&PdinV$1p+C z-IkH9;6$1qa~MF8@L+yUC`Iohw9rmjX1pq7z}!ayEh?i|19MzvoWvF%0S!XajA@>< zA7=x&@#N-`aMg-Er0Q9n_%z= zIkq38xQKS&KC{K@6U2M2l^d>rB_}9Hm3#Fz`nubPJvFhDcLJK!fJheLdMMSV2v?=< z7VPC*WihvK=Jm*{`GHod_9NTe_5Plh_k~E1GzPj{Z;wSCWmGa-?UEk1emKG48mGa zHSSCC$_lQmN0EDqQc#YA=o7eK$k6wfvg_AJtfFMvSd)!~#G8J8ad~ZWJl~X*8zMPI zQPz04)!s8%n0zDD4+xmMf-E|Fcum*ZhWcAQS&g!f+_qaiQaiCQNg_^9KilWlv23U!s8t2_gD<nr0CrBcV%W z;tuRu+=7?T#f#f9Jq)Q&Y0Bw^(ZhzZ377nwpLRbpj@8h}j!orgD*zt5S*92eCmVMRH!{Z?}n~gWVN2egX zUKXUF4hZaA^*?@D(V+zuPe*WSAq3x*l1va4qdR|!{l~2>BK!$yJx&dB2Ek|QHpgu1 zv|g{;Jti|A(!6I6vA!HOzMYu~gwFC8stm)2-obn!#bbH~?jl^5_l~Ub6mfueFOm?p zj7}_Zu|BwXUIN>|+r%0Xfe3R^i*UEEmT^sydH#m2YVU6Sh3dufFlKyzmA2g_=Q_=~ zg?e)>Un;lBim^i`K*}$uQt#k4IU{vv5YxqnEhgB+%RXl9Gw$wi_DEq#N65XQ!sr4z zuFmTHp5Ju8cIf_ccQOo#T3REdDZ`AxBwtpQb|>Y&8mgzH74~A_gcqo`&>JeOjN3NQ z_N8qC@pP>eOSNyvx;_AgV#sb_u!=^ zA@)PVvDTzTI@wlJNCS>)ajNyKi@+q69T1p$H1qFzj{of;$q8>0zl=$V0w3@89i_rK z{^M=sHvE`>iz;!W*43B{(xNs(&mp0rIAf9I9)M2z`A9KRs85;d~I zA%QFFBCQ^$Ob}@weBg5x7#5T%A;c(i`V`smKDpeCx{=Ql-)6Wp`hOKE?NIVIWmfm@ zHL(QYHI^cp`6Ag4pCf6Mw@K7Ir=+1cv12=E5O~0}XncJ@Ue%cy^3503{-Hp^+E(>sSxYd&LkJ4*lz5VQdN7bDzs=KHWf1UB@6 zrVpC*4%Y6zA_chmQKc{9)nrtJM)E}QkecfIT2gM1nd82$BGCq5-I0<1l31t29|Pq3 ze^XljQwjDzH2h0Yb>hNQb;ve5GQQ-kwgE#xYMxSXKlE>xW!}LTt_1ZyOZe1L?_R|jXnh>sIilHKK9$6oIvX&`8x=kDLXlJzIbLqhy0ir6E16k^Pn$v- zOQ3(==~aSe@b0qOcTNI}1_KrXu)7~ilrAbA-z&P^$i3CVgU3)b>kDXfzR@!G5zdb1 z)>S@GpBZLTdY-&Jf5hArqc6A5#$!8vPt#eEVXIons(tizy<<1ZbMy)pE*`lxoWCiA z0vVSnIoQgY{0^=qs?@_=+hV{8pJ~O)9ECN+`d8>LcD#qHXlO% zL$gVXc0tnG?CA;p81cg{>Ay1!T{rVOg^WfOem1!=6m8yygqrZnfO#!eRUL{kblSLCQSnb*a+EwJ>PnQ)wTsGg^2V5b)WPE3{a zzQtG$|7sX}xW3q+Pp!pixU95uX}B=dG&SZbx>V&>kMrFs6-|Qg%=ZtOxw%@d;mZrJ z=tEfFUR+UVTkdz|&=sXla~oII0!~XorQ?Vz7spD@p?@L97iUf6LGHA!*p}AC%E_7t zgYqgF*BshX|L|@u2@ZDH7`sYUdLOq@J9A7d*bfsG-bV z#*rWD&rg>N?@xfNx-zfiHg7kGy>4Vx#CrI(cXZr{7B}g8`?Fyp4XMuJ_=2KT6#SM> z*coz`5?91ekXtSJV>=Yr_P_NR={ofVo#cT+Wr>ow}SYA|Qa# z`L`%CNeA!G<-zKR+?}p$?Kk*^vNa5&R+TiioM|r%TFqoP%Bz+dY&K-ZF$kp)irFW2 zV<#pu$a0gj9;>6hrE!C9!~k)9e4Xievb@ybZJLeF zf(f zU|7B)RohM6v>_qDYPV%VtcQE_dQP5yn z<}OAC$D93&(r)t!EwK~Cw8jPcAYv`OfEK^O5UU+-H_rhyVImxihW}uSRg1J<-qQ!$m zWZ7=;%H;P0-6#b?Pql@0(;>^5+pQWl5Cc3_H(wQ7(IxBOHdVoL zS2e=>j}4PqY2jq7^yMO091xo=Ooj>|hQzrcU%19Z$X-YHp57I}s0eqZMFTu9zT@6_ z8XX7G@c*OG16WaGiFoonOs^r5x-=%hNctDK@&S`>wfgXbEPX&Yk@A&Q6%aU?^`jO5 zzEL1sYY{lXKBZp9)m_dhSgob1LP8t-Mt#SF=USut9ylp;LKlqkdjyIhK@98o3^9jqZ;|-x9 zJ9`Dn$#7F9L2d-1{j0G?R2Gw{Z5+A(yo8Fq1Vy%&LmG?bzjlADT?x8VS}j)QOjssc zpozFAR@3%}L%h@EOd=?pHZSg02*OXcZ++k-7(d_3wQ#Uq8VqWqVb{Eji$2>%2!~@B zlxbr$wqgazQ8PnUHWJ#Tr7;%0$~p@5zbcS^z1_H`8n=Lq*qI7-0T|OlVp-D=996&m znux8@fv-`{8C&_HX0xoa&;er~%Q{uP?yAx07z_RN5i%DU=mowm9E>_%>)QKX5!sqHLt*4=9pv9FUIH6AI=ZW=>2C<<>OD0-;gAKH84qHHqcqk$x->-z7IoF^alX2ca5Yv{k;UwJz0E|QI? zo@YTwc{U3~*?CP}FVJe_MqAtDLIB;#7_~dI9-Tp=7_z_6NSU00=<+sjl9acp3zkOw zZlq7|jYIAXHqB?5ZTU%?=u|%PU3Sll6?>fK$eEKc<_4_rCnL$Jv;oS_yo52>Ibx&I zayfpY%z?DD=+k@p@@lSvScTDB%JSr73l=*uQtUGTF163!gx4vNL)HhE#=pjs*etog^xSA$9o>vW(+p^bk>Nc*ZPc{EvFwcbo7jQJ6wSXL0n^HH*4Xr-ewMC zZwwiYApwYQmsSHpI3287SHK5iVDK6w+iBT9fX#GL^i~R4Yk!i$*>=)Djpe-Ob9zb8 zy%=$CD#ZyOY||3`V(m;Qtw@AlHI;_$wBp4e@fWevGHTDKInT&Kh?@9q|8~;WTjWM)bp+e0w+OK~fZ4mZ!`P+s zB~D;0f^e1Xg*}$~jFN(Ad-u6)X(Luz&2ytPx)H3Eivp@d*7wTc+cKpX+CjzN)^BReXWVz>h0+0ScDY=5gH4%%H88y^CxRklqG5<;q2HDK|i_v z$mz}jJ}jV$dCv5(%X|2$Z}ybb_S!2$p^lULI}j;tqdp#QSA09UJaccg<5ia(r#xe* zaA;&izV=;Ls92MQxz7ocy=n4D$;e;)3EF+Qxm9N9O$tFWqwZdchl|ed+62mu34-$~ z8iph(%+(kbx+8>zR>Y7JUKRt!ql}K4nuRRxv6aC~z&gav%<(w;C&fp@l@qih*qfdw zQ-bBf;Dt3Ye1#mNp`D>ZZ`weAdVwtL2gD}HeAe3ju zLhbpZk}8&cPfXiPXYG!QZ50C)g#j!sEq$ziGHg)&;9I>A27}xg7-~Bc>A~#Uts-lJ zegjf2V#&qiu_}iMVbE9wo7D_wOf+mMR(o7}qPf=e$dCDxW9As)CT%98ZA+JTZ@LPy z=N3L*o5%kI^AOgTA~fX8Fm_TIeOr?ivdHSNo{4Ui23`F<>v>xOuR0p8ihmRYNc)3j zJe&3c3FzG822YGF$=lxIG5Q!NN^trrDI*k%^carwLijZnfSdaRBWTruREh)9xaPK) zE7fTp-lM0wII3XWp<$Y5n0cWe*5H7gez(Vq6~|;IZ_Z zjFRekL&-FXQL*+pOX!FKsjh^>0gFMh%Pr_)US2@ponY)Z+DlhC^)@4EF>{PX3uWmSdKH4Sfu+iAGyZPZ zvzls=*kd%x#i;e|0?Bhq{po;ZFsm{3!)2-L>RM=YO-mAk?+Xaw=dWo~bgN+7^Ub^V z1{&P`79sUsl{0#)xhZY3;bn~kOz1FTuRIv!hOE08n^EQ0P`KRpL-2(Tqx@MSka9N#pk9Urjzm(y0007GvJ(P%wM~rpetVQQp%AB z1a!R01ez7w_DjI=GdiuIq>lm64ke@XcL2XO!W zD!@Mw{jV%R{DZ`QkocdxEAUTj|0lNpe=m;wKLf)Ke*!BA&>8`OKlg`UIG;RF`Rf

zQ*$XsRpG++w*!MMXvP z_6<;*is~xm^78PF%arCNhw>##bIC(nNuH{5@ZlB})!$Tafvb-}WTuezhS5hyNTECxM3Ev6Mb$sp$Ro4uE0o1(H zV4NTNHhu-|OhLE~?#D;F1mmoGiIX#X(W_;Jxv zWxu4lcF~^vb@};4`-A(=hl{79lKsE`7w73M7k5(aDXy)l7v=Sv3wXedo#Rj|-eO*g zxR*J)JdDZ(+1bi$55C_z!qeGpN0w?*q;=e8x#;yHJTWPYSHgwTN6q9-h|byR)vFFy z(yxknJ!1TiaKmphXbBPyIemJVpq-7XTeoj%iBa#RUG_>+P1m{+Be>(wJQND43E#f%E;@ z!VvkbqNd?jMkqJ-xNdZ9hAgH&MMOD=hWBLwcmbfg?Yczs`9Dga=~>}_b~3ZZ!xUG4 zW9+&ENZ&KURE=Sy!Zzwz5liup*WWdtNIS%dwMrXbmMZJnl{ycEP>^7yQz%;FJt_DNd)A>_8IFD zoo2&uLq*(Z+BZab$CllvqO_YoE`-q}OS2D2fa0Mi*Na1hr$i4<-6P+t@sd9jnrUpF z`2srvJXmI`9#O%gVl(Dz{&XWdf}~vbn+y5>H85a@mj1GLQq-Govbp>WvQtU&Ib%xW zAO|&tM}W$MgWB4!uVrf>mKm$(@!4`l=N^T6^zXgi0j(cYL40jgW>Sg0=eauDKbk%d zLLR!YA>Rkuno4Y_+B7NwKa~%jm9t(*SQ+orv$$R{`y?qChpN@ynv$+pbNjhfCb^vu zC&w7SxF}W`97P|m5Kg=3-p2=Jvk|Z`u6Qoygzv4#-(Y~R?-MV$@VaEH*zy0 z0Q_^W^-7dSz79TX=a$B5)SE4gS0}^kxx0Y_XuLiCTzjf+<$w=oByushF)yr7&lGbn z{?B>^*ywojWZow`_&{s0CXZbM7kBSg#O|E+To6NE0KV<~WvD!TxxHQrm1E62PsvO? zZfndIe72d^JdYul2V?h^IO)`b|FB$kZ5~_lhHn$Ee=-v8x?`6z#{V^d^uuRuw6YW0 z!_p4b_wjU@^k~&Dx<68id9ZjsJ0?Jp#QEA5br^ng1iqa8?QPk?cA!<)*AWFSzP|D* z`DYOtwCpdqXN=6yBK0P9mORcz$Vd1Aqh$Xz_3YLL9sg)w3B*X5!?WZ5j+CNU_J<5( zF2957?xSCz6#tPif7xHGmSbJlBzuUdI(MVX-U z7wspoWYQyJAdr#8bR!QCuI~5bmnNPF`B*sBWKZ|DzOilA!R{8l-lAXjGxn0kd~yIL zI~!y-UE{c_rV~W0-c7?#QxiCgkbB3jY756}QCaEE0S@iIe%poolddW%I34{uN_=T^K2~Ne{#xANE);in1P-A>+~|)aKE^9lIB`B;vl<`RDT}4 z(!WWhQwk!i7IWyA*MH*`(XJQp=_Gcgjx4Ij&5~WaiF7`5axQBH;k7$P7tU1qhOshk zb6POhvik<&HSk4p^n>R?KWk_5!jGbuU)XE4r~x~^HtyDFUAd-v#Ms@hk&rQ8w%`jf zXoKbtFuypjQB+}Pl@aH%IT>$w4TnPgrgFD9ul?Is-6Gip*pZd+ZSM~>Snx`iT+`nY zRm;G-l!{Cl{~vp&C5_PC>DV)ix1eLI^AF#GuVB&}c=C)wQ%u?>n!HJg^a^L4 zw;zaj8K&t0%LyqmqTrXR*h_gu0?G}(RsMq^?UokT;(Q(7mMQ|D!SAavzxeLc|K+4x zpE2PVEF`7q)3-dnw>qL8Almd<46RQdFHRx@vP-Q`VxGRfJqHH_a_Qg1=a4Sz8UM%gYyBH%kECjas}=oT^a{swG}mj2&rw1?MjRQ zz%8YZBl+nh@8z^`&n@1`g4xHm6T2JQ!f|Bo{W^ndX-vpgL5*Ez6`N*!3M zKVag2QyU@Q(I_PtH2h2BE?M%ONwe5synX#`)4d*BfsXr*hDmmJ1)Ypr=f<%URmers zlz2?=axF4;RCr{dw3idm=+cOV730EO55rCmMJ2o1NQTRhE{O%drGaQ=DbuBc39t$5 zCL}0e<@#Z+`y{^~x6-qxE|mk@r!aExY0oJ0O3PM9OTf>BJ~g80*=k)woI}EffJ)tl zqn}rv)5#8;gkMq_3s(~Mg4FeVdm2yl5A*L>p^pP`%&TCZGau$#*1xhAOf65fPrWNr zo4t_eHf$kl_Z4bN52mll#|+-)c)%p(s%fRKUD}*0sAPA3)Rr96$G``mfq(siLbpBt zt0m{8YnmCf09%U}R@FT%=&Pue==L$!@l17Nv|V`OP& zO2JFXk$V^5#eV@~A}^vL4xr^mrw9(|G5jyCAjk7z=y2@Fwaj*#nnIQEbt=R_??fO(=VM7d?)c%WAa!a@GQ`WlqgV_Xt-d@H3YcAKfJSJn7* zsN-3ByQA#m7Uz)L#|Uzu+bQ|kS!@oVYqkEe(qw=JnS^IBo72;LFY9d2#KvT=ezws^ zKAUA{4^}w;OVjUbF|~k5e7wIPhpL#3A;3bZ>vf4x3^P}qBC9p@4t-zcy*z&JcZob; z-*SGTQ+bZ+i5%wB@%%j^ufFiy7}B)zz@2^{@A88ZD6o=OS2PS-_X~a2H$u8XL_wYJ zE~O865BnC+(oPlD*B>qKRDc>KGN)>};!yL&GCyK!Z)>w%HTIQmMc;7x_Qd-0YgiFb zOrrjDFyZ>4;;%GjNxfa>xk0%%`R}BQ+IW*jQb=OVN5<49ajbu*=9lIIL zYO0J(o=e`R*ue)Dzwnm^%FmhnfE_t^;!P8{SVfUimPe8!K$*ky6W(&K?dE%-yR=$R zyD;QlgqT*@_#?@lw+s;v$Y-cA0Al0zgi0Y4TI7FR5$G%s4wWV4yecEfx*YM3Jm?~O z`FkRa@i=W<2Jxp#JgbdNd@jeh0Tw};-Vh`4sXt!WA2I5wYK2Vn!3w#~NfMS-h8k)d z&ebw4qs~l%s)YBTMRWwV@*4wkKfZy(hXKw5K1;JsXr0$#F##ok7p_ON!>S1QQbYV> z8Q50EP2BN~;|3^H^mpY^$mG+7HoH2(vGNx{C1`OTr&WkVCuO<~G!UQ{8`+0)=kMD?5;*n_%Ls{s?DV0&+b*H2l1=fK_Y?=(F9ZK^ z^qewy9R)>+Bm18MPA74A@|Vr|_U%M(lBCzmp+_G@MDA*yss+zPL_&Cd`H4I^lO3KZ zR|0^$)-y90^E^;}5Pn0)JI>nn=UL`ez^(Z&`V4m^kCflqk<7T{SU0oj6>uDi~W_MR8OBl_#%QSIe zLt%qml-^AL3l8?<>Z1$>|hu%fgIsSeTC2UVM*)8XZ zmm1=SI6H0bzG`Vre9ZgeXy0YAf{|XqGoLs=?ztsaoA){L6+RAapUXBQsN3;1E#pzp zMo{bfCjE{U!Vujv4fO`qmgnCX$ZQxWzdX9BpvWW2arsW2)V%8T_1{|sAph0~$Pb^9+ zq-vl3GqqgSYO*2FNVoN-9jeFd*!V&F9D>+NcV$vQ*5kcdvktJsaz>~6AlTqXv0?3;CTNoYg?^4TK{zZWv76n-G8UNbA8bAnpx~^$cKRUQ z#K~lP%KD?iH!3*!w?l%}mq*GS$!WD_%cc(;h8?5fy8COh<+q8albnc zFxudd^5e&kBbVdu;~h|Vo`aCUws`4jN z2Ge`CHK63z9CCI{TO4gi?Tw6moDlrj#V#E1DnY8kcv>5HuqY#K`>F(b6ewdexTymB z*?KU17}W>77givX#bLH(_OjX5=i$w^fX`f6MSniPTjZwbly2?{*lRB z=LusH9^ZrzV9dP}3RUV@$rLhJn&)DtcMFh?ROT)%?pX0QaOyz0yB#FtEk*|htO(7I ze}$cnTsLhTT4?j%Wh9icsuTctP0gAZBcpsqb=-Qiw{Qi0NY_J(KC!Zl`0}%*ltyDa z>?p08dXA=ebRWB~RfvuJuU$c{M>#ETGLM%F^qTvl@EfCdmLp+VJYsbRW{Ov;?;Acm zu6^%Rc3(0`jqm_TCXL03tv{6V_hY;j3iQSt5cU)NkGJ*4mK0C@Hk^=O)*D?k7I1#O z??++n=_gcH;2{1^!rx);%8X3MgSbz6cu}Zj#Yw4elZJQQ3R&ZWj=iHH{kfNL`p!m z1kFF_9r5Onz5;tAK?kJKCQ5dUhuWokv5sIg93&Uq9&t}8UBvaW11rZ(yhO@Dg0D0? zKJ;X1ziFOg>&TOwV~9qJ>ptwuS?^Vpy5*W_B%8Iiu`feD3+rHcBkZLYlh^KVOnh02Ucp_wvF_~z;m zLahRSph!x?;;J;e#>=4@7lh>7gvQOdgf3O}H#NTtR1%b{1m?4ql>!#^5vf^W2uTQ} z0XnTRG~u4Sxqu*_jV?|G?rHh@CTXq18eNdM7chya!AL5 zfJ2}gF$;!OtvaT-NX(4b@v%vEk#1J!_UxP(36GC%B^;nHUAnZu_zf&E8DM#?cd21l z2g_o6!?Ufff>hUzsp(MB2?)%$vkcHj4-nLf16 zljSo4S({ArUQ>IRdH7>)^a%x<*4EaP3u+`9$#-+$&P|P-BXIog+|{JC?Zja|^R#W0M7Sx+b7^V8n=fl_;nceV zDh_#>F`sPvN^e9`M-FARm4>v=Bd6r>dNK!;6M6dC7Qvxu@u7do zfe$WEd1WAjMo$1vQV$Y?aT3RqYdms99UB?ZXJ!fgK~2>#gQ(cV5- zH|;A~!Od28yZ7o=(TIv-Ke))rLA>7Q#X`9L{iiQOG@smil(xQriyQ?lZuF| zqSUmSB0oPX|A*7Cdy=+ZaeieS;Le5bb`K{?o_&;2SDG1W?%syAc6qu5ZBr%j08zDX z!T;etUS0t&ywEK}8wOLBCj5Q&31g;{KSV?ygv!r3W92nTmw240Gqq=| z*lPYuy4xF(L&-@=P^gkLpV!i>wEMTT#+!iE-7EA%eh%htKTEp!>$9E!SG7-)e%Sm} z6FE55e*0ftAJQt#_)%z&wSzVsp64mphjO=j)i$?t6A>Bx$<5$C~(ntT9{yZLD88i2V?Y281kj_M9VgIP-(rmZLs9&%kWM7)#~W!a|1t+uEE$mZns0QbE=Hw#8De=u_~PRK2~+jIKJNdU zeA)jm#s8Il{7}Wah~I=k{9yGZeHRL4NrUJxQT>rbON$amEN<#tgF+?0kJ%C1JwTxo zW`}amo^yFe`UlsxkkZQE_B6UlJXQ%%-5uX6qL||c0RYt%^&FjpKR@sZ;$h2nF_ayASYM6G4*LrVg&y{C z(^k!FxnrbX9O@yZOtJgjB#h-wY?YzCl(pVw*e1KG9g|5xRrB7Wo8OCO!~Lw<<-nr4 z1k6$ann%4wz-Pds@28`IX3;vH%d-_8hlHY9C+fj96M)F?>I2-*9NqqqGO9de+{r%h ziL@=aB)(s^&V8^%wd}7@5xAxEY6t03On?9-(%IWlpt=^UIB2_pUD%lND1os~aH4+| zLZ+KT8Xdda4IT6wjfBO3wH{`EC*}M(I|ut8-8;N_`pxoNm){eboitYOYwSpS{O&Af zetv>OWzexA*+=H3{*r`@jTkK80({k~Na&2NP9+0bo z0j<-nVdLY%IA!t_kUMcZYpynA(puJn?#5xARZ0fasd;AS3!q$3pl0=x5V#mNu!P(= z*Yvlo?K?PkQD`w(TCHblXRslACI`Y@P&_f>feSP4!(dC%4DTPz*M>sh{6MdW)K)z~ z1∨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 literal 0 HcmV?d00001 diff --git a/app/priv/static/images/scheduled-publishing/scheduled-registration-rejected.png b/app/priv/static/images/scheduled-publishing/scheduled-registration-rejected.png new file mode 100644 index 0000000000000000000000000000000000000000..22d8947d407d0497f1870a2ec09006beb10f1b16 GIT binary patch literal 27393 zcmeFYXH=72*Di`(M8t@If`A4PQHoNf`$%ZgM0$<%5;}wkq1os~I)o-gdgu^pf`T9= zAieh*N-v3U1q*4a`qx~UuV+!u04QnEt^jNko{e`p@UVv`-UC z5GsfgH=jQPYF|-ZJukFg zx%mA2`H=s1z_~?Kt{)LJvIR>*d9)$r{hPXvK3O=Ctb1yiEVu$ z%YE5cVE+^=4lfu^-yTk{*w6svFd@Xh2mx7~iG=Ja{I(2rWEb(?i&(>kSX<@LaQfw?EYUS*k9({Hn z-V(7en4(b2`5))qMA{PI1p9J;Nu{7Dt|kg`MeeAFd zB`W;p%vOk~7ss`@MY|F*+bq7FyX*b+rUNG%4Goy^Q+Dzn6TNsp`Bvc`x2_<3E#yfj z3tOM9w(6Y-1-Q3&WaEVRJFEW;@%#K$MTv%nn}E7a4p@Y`cF9vxThbkDXV4i=qNlms z^*sQ$Cb-8z2v@rBUmm{$SjchR`4T*7SskG+42FNsrpJmIIJ1ZMDnDP$||AAl@9vfMs1tnY;Gq^n*vw(LRUbbv}z8wIcf$yjJT9ry8 zwi~Khq9Q$BoE!8I7#-UVhu6z5>ZVvcq>-VE>#(dAzlyAmHoFs@j>pGxN71>rX9s1+ zT9lF(>LSjzg49Chf*)ItT%5QBt~W6CBQZhnb}57CVYEt?_#8`bWcPESHqF!Y|^LMd$xB4a5Q;(auszCaJ0aF zwwFogyDG4n%$Z|TJ6H!qC7-0A-8@2nfcHbpobWpPfhx&|H_eFU2p zDp;Pvuro%}tBEKw_MHB$Epj@Gg-Pvv=pDgJ7Sq}fop-^d+8gd&-_QWEoIEt*CypA; z5d6=6?e;=X_XYY^>ztESV@}H(@F^K9=~73hm((5)yxdRtc=^5BK(Q`lW8cWR$+sL; zV(9Eli1Iu=)bRI)_RR|z%TwX;p?gH)&10s^iO(NseTUcIj24S*+BtEmTgXig(;4YD zl7aS)D%0@l%D0-62S07=W%yARzKgUiUsw7|SGSpr!PCOofT%qugb)Yjm#qF*+x=&G zz53N+Qu>>;)ui7AzD$mt;YaceSSGeTYF#GP)H*bDw1|a25aUOR5g*Xf%Ii)7Yolc? zgOwn-!NIX5KLEh__eMy8{Jt5Wk40Bq*`$t}cyzUBms>qm3iI^uNH=+YFhq+{CkO=M z)NZov?7lJcZRo)Z^)@`YsTGM4!28Wj!6a6u#Lm!HXnK!?2(&^W&^>Hkc0=)-bmCp^wuxDED5Irs9Rwh z8LGbB@V3SJowb!JMaoP^2#0}cZ9o=_HGRyZpxq{07e2jTC8Uy(^XI11Y= z%Ohu97_}7Sv2fnN*AKRQ;bjy#0ap;uvbNJ32%dSW5+X-qE0Uwb#|{2R4#N>*(=TV~ z>jN<4{ryXPm^;xgEZV|ZC5dayIDF`z(K!|X>+zx22fY&xFf}_MdaEOmaME&#ew^*k zhbfyqwtp9}zqG|tA-)AeR-7u7^~LV4_&R%>RWDapr_V5WllQX0#HnHU#*qJ@)L~w! zm)}7^<)+Ggf1kz;c7GxM!3@{a5f(69cmejcXQtG5D?&D7f64S<#`JVzx%ULU=mc** zYKX5qGd{Y+=b^wsARR|^p8rlZ$*kP`NbN|cYHM`zc|gpHuIhYE8Gjpda#u~7O?v** zOiRAC!ESl*S4HT&xKM6R&mRHjA$d?$Lg-Rup86)sGj%?@=*Zkc0%6i$A7rSD{bOp1 zwmVA26%>rA6#51RE7|&c&2<-Oy|Y;?Ep!$Gfx;t?`{jl5a@4V=+BIUMBbu)A(CyTM zF)nQV!B^~ze}S~9J)%CpDvQ%!VJ)s;SA|6w5M73{UOL?v97JDC7+L4==>vhf?ccrY z-|R^}^(X>t1)X;&zK*hFRMWuPOVWA2cGYD!iuiew#4X;e4UVZiHT(s<<5Z+C1GisW zEO5Wkl~O%wP-+;LhLG@G-ciFMf^AQ_XslJ!QYAQGUh&R3id}NFk|}$|Ls3jK>#iDI zzB%(1F%cOBy_-+ORWD?wn+a2;v5~Eg)RDcnO0y5mIlKw z86n#H_gZU+nF0-{JZ{DSfocus!8n&bko6m-)lF$}ObKzmt@iQ{v$^Wz#$~*XVEr)+z$)0_~|EG~aY9uQj$k!kz)}$@kX;RTy`UKO%h`hCQll6`K=_;gL zRU2|>GZwvnc6P8ERa#4MD*BSrsq1u0)obG@L(hM#0+o~Xdv$adl$K6vBAxBWw%@2^ z<&d6!uyw@c)tQr%56Zr?SzV{)5FH@!gnp90lJWW%5}%*zvTFI2#%tAoe+ImK_aHRb z7OUoWQgySpYHyJkxcnAu5uG#nOBm_o-t<_oO5@Jj=H_O`MQLq?lxu^BHZrk&47lT- zHp1`_zOZO&KP_Tr6OSi%hvP;}o57|h>jfhpBb&Jnn)0Wo@sLu>*+8p{JGGL|_t@b- z!ry4$0fA(WqWcMH(DrIa+!TbJjROdrDs3z*@xi6b3#kHubLk6ao46R5$o*IY@Ah^l zwdXV5yT5+eXDN*kX8iC=l_k0(W?F4pcmjGWWQRom&Y1xqPVPE5Ehk(>!K6s$s7bG` zEP*MXBCbBsg>3Ls*2E|M`)dt4T;p(HDdrHL6396%LG%m@4zIzUu8yD+Eq!&)8XgJG zo=4Cwnk82>$i-wE*&=^pksT?;A$?L+vHvovi@C45Eci+2ljUiUd+8Xjx$+x{1NOrb z|76J}Vvce;wRQGxKET$&A`(akcJXb?2n~6k2C^o(7a+?C zG*5S#No1;b*2AuZ=NSyRxu+_lG~Sel3>Snw2@O`NLX9{u0=POnli|EU@{6xJ&XNRO z=l8gvf+t4n)`}C%Mo`U@LZ7Z9YVFF)B zty9uIQxOp#dz|?hiT1?Kb!@+6!1S_Qw`0ufdITIND`X7CE(uFkO6#6YlV8lbfUsUq zW~Oxw#64hVZF?TDus^GPa)-}6aBUf9IGXsG8D^uSqwj?8B?nlBzfjV5Le$EJ3sgPz zrR&;$7~F4o2rAOMh}&cx2F#jy-R!60BF?b+ov3ypV;&0WtP4aLF>y!uADyvD@w%<+ zkEHp+BO5ICXn~5H$IAqbKdA#}8q-@iBl1z?WdI;-tzOGioY#Esffy)`L%>@hB?#P|3^7G-1m z@FCzFbk1g(YklukdY%W8=QO_)9e4V>WNJdMy8tkBG^EIAJZq5GH=y|^I9M5kH9hd* z)l7+JLH73n=k>Rm@)91mAFEb`fI*zzPDJTSLQ-%r-$58oXU0_029sZMW#)+oU8fiL z*>29f4ZBJS-|?GlK4~nYq<=35bf)vUii2O3{H=_-v#~sOCw|hTJ@`$Z+}AOF(ogn# zfx!@5rK}3Pzz@DuI9YEsB->T#OqBLp2j@!ZrVp9|GIbiE5m}cC9H^Wg zh~Zbu(bTB3!}hrhDWeA4Z6<{X=EImCO+YN@8iBgw(w5(i=Dcu>h$hG>ZFif{1Bf;S zcXaRr#2@%A@A{WSZ36Hyk_txgt;#}e#>LUCLjEs(b_l-y&p;af&oc814*opD+r zCVM*%r=~c~kfX#R@`-LMLISYRq+%f_1O4MZ4V6;Dfu0s+2&sDRP+tYpwUXx?%O&{_ z591>fiI*qA-pee>jK7Nu*N!xg93sOVRt|~|P8Xpz=`CAl2HTdLbrrc|_9=d+VcjAI zHR}FPcv=!Xu5j54H2pO{Gv7aOLt4x0l-T)x|0l=Lk-mbBr^}JQINw%TK&>m?oZsJU zgJMPD?Yp028Jr0IxJO}DVZ#l!nI&*MdMf<}EJH&6% z8S!Rbdp|O&as*T~*7l&|)>D2JOWeq{yD&6Tr9C&j%ZfbN@@B!y?Oa0Au zr>*%g{(>mP{@*jVr35%f&7;%Mmbh=XH*q+8Bqu@uaqrF@)w8iG*_vH(XL&gMY&}tp znmX}D(9~44N+W!E*N=d1kAExsRCE~e?fDuWZv1)Y)hu-+wg2fthC1mmNcM2rXBX*J z=k9#671TZ>OkV7WEp6O6p~S*dKD$^ZyJnA{?(FXfT~t2!?u}93noioY<2d(Yo#{`1 z&z3q^xJ9BV5mt=e3`X^d^^^f?87-4YcYO@G2rW-r~aq-D3jRl_9`T zbh)`!ppaMR+0nEZaORXeeucptYQ@;lA6xnzw7%Hj$b!j`%?9c&=U5pPhH3>8b^9)a zyb*qZJS<8++g(q2x-ukFF&FYz45L+J8I?IM_0{S=k1qU8f|1`_&9U_TA$JW~ZW}xG zmfRSB$)uQ+WOI5z-##&r;LK06TKH@?x_aQQ&e)bTa$~|MQD8J@bgB;0KRK)pp33d4 zkgJ#AIf(wD#sAz|ws^3(-ER*`@Yy~2O(Uw%9cc$Xbzag;)Pt?E>3WFX;SsZN%RGLbXb&;O!Hp@^mc z3wJIYmP9p-ll)8k$$3l@KXLVg1b;otSL^E4a}*C%r&o z9CuZfS(^J21s%r+g)Wr^HE0D<-F#s347mOS6(iujh`s)$GTdSxEC|gc2cPs^ z{r+gj?qor>u8FYy2Xi)Y4#`6%%MN?k9cm3gx17%g3M=S=f;f0V(Q+#KW+waTc60={ zylP21w@k%sI3{5FgUw9Q0zR!EbtW#j@9^le#+e~Vlg21*fKSmcZaXoTvgffnFMyr3*AQtXdNbl=<@I)6;2ga_8EUgjY{6pifEPbx_B+0oV!V_8J^7V$ zzf`7Qp^s3=HWy4jHVx^Bdml4B{eCm%w5bx3(Sr&D9L|0A(ix68JNhu3M4nQm&J{X{ zBDR?#S~(v_=PL=N>o+lLq=-H3wY4tIEXsrYx(+n9BIgC9bw3Uc!uwG&F^H%YP=fUo| zK91#?FFO2HVF)#|+csW_P#(4{S{f`!gCg=udT~6hzOo9v^+`5l-J2h`;D)U)#VK@@ zn(oAJ;x=iHPT;hvWTDhw9CIU9IY!oVWQB22=(tUTj_5H}zu*N^iW(q&y{N0{ITLKz)N6!0;xs3@-*N}-wt_(t^G znoa05=BlyE^;&@Fe9VS`?BoxOO|TLHPGO6J=*XM#Etg)Jf-HTO=U;>X%^yJgjt0}l zn-on6faq-@+0z1}B`nh!At44fO%LcAZLpc%#-yij6_8V9RR`g+eH1rS zh`lKGK3ivJ?N(7Pv4KM~a$c^D+Sy1Ji74Rf?3nA;=Cragb0)|Pk@8*(d12!a(mBSu zuj6LQQO?SXpBx)bWa}mm?iJ(chLhaIoc^%(?ZG{xeM?USe&e3;teAFJs$=|DRttQO zVXM-FZ{(=uMxr8xPgG0QK3$|gvR666EcKcgkLP#bv|=gag_f~-zwG@6jT3<=91UX2GK+nj>(MndOCuw*_j!^J_K$P^pyIcTnUiH3=OqT!i`5mI)i1 zX0cOeIG7GiH@>;UfD`20j%7ET5M!GdiM!{k7P7}ifWOjoPJ9I1>HE5ODOA-~G?_QhI&u*tY1 z+~soPn)B25=0!!kMep>8?AI?yUno3Xr;Y51K*Dxn9b8xx z+gyC}HSHy_J+1E?m|Idiukt*Pz3LvX_ec4{%T4`ej7krXqfYfC75207w9NiHO|sQ* z=2^uU&|BpiGu~_Y(+7UOwSFgapD|)pWs@%L_eQ$u1bjyAwJ1Ky-U!dd^ENej6db{d z$hB5_XV@m#WYx}(C{m?7sGe-R+tv$X^&;)B(!vnV-@Zh3*YUx&vA!RriXD4o?y~f$ zRV9J$Lr?OYJ51aaXDa#=pPYM=s%O81=&7MGa+ovWTs7%7cGJfyt1nwoBC2tBn1S^! z3%Q9{U3#@|wJ$`B%lE@e_=9x#zskZxbgGo=hV7vVppKMX@zy55_b35gEk;Lc-=evRZT>F$a?+e0tr)?K48K2o)cfD; z^y65Z0@P>2OTPUV;pXr{PUP2_&B%L~Mz=}NL|IQ2y0E!F)9%Yq{daY1t75_Bs{k(z z8mJ4i?zv@UpW>zG|$|DO1bjEBO!dhzsU1x3e z78hlw57%cC#8TCzGbH*P(0y*Z!Gr(NQvx0dlb3V8eCY$`qNYD=jz$-y(6CB-FQ%7)HHWcg)a$K64tyAX~IT7w;y~JwTbJS zzTt77JE*qpk%geLv-8G3Zz;@sLP{|;HHYYJmH+f{&Sme9B)0||Q*=Jaxn|t?t+QYV zE-UJKq(b%`X-2;TrInNxlhubWLjK^ko>{a-vJ@ou=BWVxL)UKwgVZSk$Hc$@{fX<| zYU_&y-sKky3fu_ADA6z_7%Zou>$&;^A@v8Fc;+wA%D{MKPG+OUvAYq(^iCqhlz(k6 z_J-d2FCnBTmOGMFmJz=h^^^TQHFb7d!GeW~8HWA8l!wa{U?vab=4k7n2s{*3s0szk zc~)=z_(d`$(xX&YNT#SY%P+DOxg$N)Y##mFM~d(i_0PYu(*IGd>VL>x*&1xJ68$3Q z148wDQ6Z{st}VLcUr8+BQTyG02kL)C3;Q4K|7ZIB|4sbwNsIr7-Ff(GOOL5LNy1kX zdg9{A>@Zzr*|Rq|$fLCqhZhU1w4qQi3>1Yfz${l9#R=VM4I%mGs%O;Y=NH$gHRLKp zd_Q=bS8}Z}Q-wlQ9d&omuQ=?0wZNn*>2=+as;jMd<%19X#+~1VkAI>tmYuDGpVnrT zMlMhVD`qQF6xmm)Z`^F^kk&6GF3Au+O3Zv*CK3ow)lkM(kT=A@an`y_PK|LcciX(& zu$%ARi8QL2nzUA$ug8XMejc!t`U~X~JfElYj1)okIj|dVW%RT{9>lpZRrNtx?K?Vm zdv0&CNUb?*RO)_B*9QIY{?C;B;&pEd{;=dAnNq6_DAr%5dUa}kLzwD)j=?*;8Y&F* zn*7$`Gad=XSo(R|Q|N0U8eN@?bXG@?4L)$PtovHrfKWcSI)yX`0Q}|gts${C2`A=o zdu)mxf2X<{g?_dmX6dA`NQ6hOorVm&F#-ZVBCgPLXQY3HREcTd#b$_ei9kEU<$p6^ zedW(}p^(@Wy(0LTqG=b+E};Da*DsyY2WfkLmaV*B}`ZM8VFR?RFqgeHC8OS zjMT5R{@DH)SlAM~>{_rQI;vUewiMYGFyCSDZJ`{;2Ve6|V;q@mG*H(8Y1`;de`;(r z;MZy0(&6RNZ`ypNq}KK4P37g38YJ)2gY;X8nNvSh-Vkw--3DIn(s_z`*vR5522MJl z))$VZ)1P>Q>M#Yl0i*Av=;Qu=Ok2Ofs8^Myj_n^aHm$A4f0HIndwJQ}M6j?p#uure zM5E zc$4Qm(-!}OmIP+AJyBue<(V2A*lVw& z2Qpr(D7kt~ic$+Z88v*6kMM6^Soye?^_yj)H9kE)YwhDDjW`D<5m2$6xYxh~Yk;y_ zuhF+eF|jT)HrufCpfL9p#p!XGN6b&fL_9-Z=2>xueLy7)v4)N=J5A{%m;qjlcP`%@ z))}c{EZ#j#F_io*1=&xiP7YN{uXbNsV_YxZ;oe2ARNZ%S-c|%*`MfHGO+c;BZ^~<0 zx)z8JHlj>WxeW0oo*}_Xiu-|%=wO6S4W!GsA^1?N*PzM*w*PTHu6s1?m-lxeU*FBx zx)s$D-FG`9@lS)8eI2b=e$oC&2Hq*2*4_+Dno>z0+y15k$hJ6%xnkrql(M!w#Q6At zmGK6bw6T^>lFzispErhm354Ft+&q1GkVN6micH_MKD>F$l1N}KU(p#VR;vuY%zEX( zNov(``|GZwjoLjJ-_)LE{hE5^GvJ*}9T zoMf!k#5l(?^?|HG({MHs{1k9DTv0Cv5ZUZA(K+&rE-UrvV1m^+O^<%7TbNXF1=x>g zOFrF+Wq4s|xtw?qIF7h9?`p-Ei1_-5B(E-7>K@!5-FZ+$x(7H3}-e)-vAo-a;nWAY*ZLM5$#k;SK zuZ!`D3Gn<$iH|NewVWz?3$bVj&R3YIsTiHaeS4I3&F4Z#IG3esHy_*E#Q_8hodQV8 zE6k$npJyF3G=eoHP7clv)C`Xgs=vimXU(4OJp%%D_3A|)*iOwXJ4&xYMnE9KO*4-!ViQj@jRj=}bb$ryYj{p|P z`Lz!rVhu+2MnC1YgFxCVjnJs&RLRq4z?jCp4(=btK8g!Hw0x3cTt5V+%jPn}X*TkM zegT1*3DQs-jH|bK2|~C`xjrqkEI40zSZgX@x9DkqfgM5#Or<**Cht0BRBa7i`uG;4 z9aQP8e(-yl1?<28$!@B##I`jB0j-CYliqJaT5bb4CJVxXhwLOzMti>T19ek2Q-8GY zQA&j(xIw&b(Gc&QJ`a<{#`~0w(pcd9lm(c13cwqIMpc3F2S_J}(upzMw}{-}eEXPS zr-rk3fl_D&OcicYtIfMtujt<1NvInp zOO3Ge=@iL>1`SgDw?7a?WFGKzSOX~IH2^Xn(~3gE9I8#9WMyD&r>k~CE~WL>q(feA$k2Aod1XxIjG=_6Xj1CDw!PC8#MIGISYVkhVVbPPwsOUj++Cd z?=l`Fj|kS7j7UU~Yms(_pjHQ+p@9pNv#ttM0qeA3?=Mg(#E95nZ=jxL9`DEd$$^Xw z>_nsg;`xU17>xz@TsExAkRrEV7{zXQ=5;{}_MXiJ$l3-!(H$wC7YR$V4!L|05u6mO zT^Y>Se>`6EBTrI(ug%fuI8la?!w0JIro!4%O)%`k`mt`PphS9+Q)KX1a=hQs{lTBF z;CaE=%7@$r41FYWbNtBnYp0I@m69u_iSn)2KagxVZDIA?%AAAR1|yi-D#resu3r6) z!00yCc+$Fj(C0^Tt?|)L^nFJ5YZf$;LB2!KYx3PZI6FqbZ+jgOr^{dG;NZDZq+Qde zvJyk>Xr(8&nH#UYyFm5)ZTR_mPVoJSXk1}*meg)?9cg3@i`CLCPKM29PC2qLVM}&j zI#O2T=32#C2BXApiJzIFrD%`ngypyLpz?g>TfN1p4l838#mD4`uXG~8EeoC#I#Uya zYyczgdO1+cfRv{loiNf!yK2Iz%dnCk^?i{X_$v*K`0Z{u$s?*mchnf2$_;+9Z@e8) zChZLUM{Z7ZCQ{!0R?bV#(Q&rWq_i&G6)w72{U9ObaXBbBeE_dr?Fs%{yYo9*ClT_g z>cMyezpQrN8ksnooT=TxYnn1vUta*V_}T3ZtB_yc3go?_(-4|ioIJAk7f(!6u}0;* zL@G3|>$%koI{netM2u8alx&t?sf0B+Dm0I<-5J{bix}Pf+hfND)v&Pb?G%@3=)eCr z=7A_~kl%FNAQi_}?F6$i>0tWol)`~>aJ;Hjc!8=V5b&O=`MTD-1Z28RtkU{$#6BWe z1l|$_VKe!Lzm2TMGW|uS*LkIL0sz0$V$JCLE@4lI_-PdEAiX|KQdA}mli#?x@e?G{ zP&?lzl_&nUqQOa{lHxMm^6Zyt0DvnK5}drG9nt*Jn(z*2hC}s3rYlVv!|NqJRpjzz_}zE0uPcZb_ve>euJDAX#zDlw!bp$GOz@*L9NW zw7twfkLRzf-&)04ogFPscFow*P{L?dt?w78MyRE8>J^r?n3Ocn@!I%W5oLWW6JH&=ePeG;k^g&o`cB$ zYf$fY&?k78iv8*5k#vl=nt`0{*0`jK%UNzrC!=Gqp{!m$+DC2wp(#Rz2Bniw%)?%x za*aaUC{SGuphLfv^V%*9&7JG~_PKklvs)RaFK}pD`=jEtE;17*wlSq;fVLhg6tRkj zH_lZ~@935qh(DQm_@oevFEhek8Xs%$)u|?opEO3&F16EA1=IbDXPV#A95*IZ#kr*D zb#+99Wv!RX}r9FG+c1!)Ac>^3EU;m*L9eINj<(; zR)jg!a*3})UAOgmwJ6?^SaVIJoYQ6+L zx9}12XBh ztGrxXbZ{`I71AJ)Vbpk8W#(;MZZN*Iv;abTKhz#-$!KY(6R-BjMP4I2i}LMt8TB6I7f)X3j6N2kQSXaqMpaS{lz_!A+qySY3- zptt*yH`v=iGxTx_Y)Itz(|I?OB?ShoaQ-7&x{I2W`#@y{uTxGweAJ@MvvtmPdVAYj z29@kj_ws$!f_zV4V%3LoW^&~=y!r|2HLX46&=EWdeM^nE1Lt+n3pMDO0Wv*31U9L_ zp$4(gbS;P-jlFlPC!yk(2i(Q^sE+*rH&f&ei)4VCMg_v)%AXxK#|?C&-?tn`oUnGW zfm4CzA5e)^FIB?&`7Tl&zNbWp&DV_cho0eI7fmJIE^wl;?v?O=J~T0!aJIE(92Wd# z?HTN=psvG{3S}2(`F;MA@!$La&4Jy}gmFGY_odu=g@f@2DN5gKDjv@o8X7KY3xiE` z9PCRZr|N8MAWue|=%e358pN$|22=Mx1~S@gk3&5HeTb*9W8;HTepTJ-Wn?_mG4fZ{ zA2KV{dl^w}mrzz(zY~z{A|I&p>m#{ElBjz!_^Dqo(cJc*DSHhgqnt#?SPF`HOc~DU zH6*PdD&OPlkZoy6aRIEjry7jbq5w_<=wH*aVX= zsIaM!8nlSmsJ2%YAKpWHGU=lr54D~?pF^#(6J_$5muR6|UU_jQqq3lNPpT&^R zt%k?g4}QRO`6shml8agQg1B0;u3ZepGH+eu`py5(*`p$NMjR8>Jk`PZcpH`d&dd3s zFTY{e^XDJ%*2M;W>B{4*V3>2IYD%J+7U(YAJCJL3^R}i#`pvwlEXYrFO&z{_f}mFa z?;`U(T~I^N-J*#B_M8}@{%&-_EkN|!BCxV+20bw@&cHXbD7#MCy$7pIxdV%Mfo0sf z0{V0TyXHoT-{w6GE|T2~tMzr{c27j)?iP_6J0|C97)uc%y^cB{-k##S+b7i*pyQKS zKPFQW%^3hH6(<`F5tpiUuEp#mSQ?A6eD#emokEn?KIFj0o`3VqJKGO|Xubli^SE2A ztMl?CyS-aC%8HmATQ|CS{-`Q@T7P()#9_EPFXjjn7PBjX@0t__>%UuG9=&v8)H^y6 zlYpmq(*yA0)bXEoVN_PN=(U*2m0SwQgDy>{WK|1;R}<%M_N1m(4wMFuPTl7b4a>YH zQfV(*^^O0$@`gc}&_-dfxmt|@f7z1T6>z{>kDHM^$l+FlEyC}IB)C>FY`~80@z%3%d)2&OOy}yVSljJj#<>=um}IPov*H z;k_@KVsKHW6z_J~=VS0%6|HTOW}RwOy>;p~LqOqUp8DMO+|2e!jSiO)k>g+Y?Jj`- z%(*_ja|0KO@OTP0LKd6(k#i6`@F6DH3StrT@DQO=S23rPB2g+`~llH5XOS^M?X$UT*az)Msz&;LE+7;X0p z#D80fm-|niK;EoPd=hsTAZqxA6(SsQzahQj|4e|jifj@}D2V+_3Z2La2*hh$`9%^M zy*r;ev{3Houzkhx8kf~Jm*CgJr}mxmv{!62_&oE(5fmsycP}O`1|_`E_{Fh2Z-wc{ z%2lHp9Vh29WS4Z&Z_cnWM7j0;Sa$Vh;$~G#u`S}UTjqu4xUUe_wE$p|y%sj>3UoUk zm~~C$MUiOOlaA#3wCLsfePp1yiox4B$(PUU43Lz)YCSD*O$z>gOayC!X=l>D^pQGU zN*+{Qpa9R~?%A)6lqF0-nSeAlC*`XGBwQThZxA8aM3<+W>?*zr$|FsB^a z2fLEVJjrZ!1rS@hu;A67-yZ`5ot}gmfNo+(R%ufEw7c-j-yYq$RU>>=o$jxJlpS&Q z@7$`8BubNKPheIfUj>D=SM zn%#*IR{X(nW4SDAz5b*)zYe6Nz=`OU0x3+9#FnT=>D(ktTVoT_PFCU??*-kt{^@K27i(C`MGsu z#IL)>K=fN~_Z4Sq0!1p{!j9az9VEwK^+}7is_IKIe;yEMr~(~gI}`Dtz}Ew5g@6mQ zpSx!IOKNpfU))^xXxN)VW=6;|qcQV>75RFsD5m#!i2(6fSs~7m{YDT|9 z!Mh6W<#=o2Qoml&O_|z^3&j>On{VcO>RFrqbBudXtmJYq9P^z&)LsMZgE0a1yGc0? zSwWa%Y#11}Ts21D7MIx7d5MNC?WDcgnY5}C8H!&&V6k^o$mOmhY2ON4 z{rIZcC-b9I^q;Ez+142!Hv@C@xKQQ zAJ?Ahn=IBHIyN)q)e^8yQ<~+49f&4#M|JBy!GfZRsy9M+8zl2qFT!{xr0pvFOOfxD z9Xy8S4c<9y6#AK~LG{b)a~b&88wAGVEC1^38;=!A566AIBHk0KB0Z(Q->q`6X^bf} zt%eH+hN^HvI zU+t-&a4k#UrXpg5G#CKXK^H%&2fes^sh=`{)B_#agh&rsssOr#$nkyXFaxCYFtf!Q zGBg}nJakkp@7ID}gE8A^Lr)#T+>OW{{j=OLcyQnPD zZ{i>cxwk2l&1wrp3~QdfA-d^^vd)m?fIZy1r3NHvRnki5gkYfz*v6=PY4!Yu55MCA zGp~`*??*RWH0~w5Dulz>uQ%94Q?gB7UgDszKL2E+bdjaBV^X^b%X!9F-WeA~ttouX zw@23UpyY4T&L!=BY1dw2^^8!Z)f{&LmG;ouUT~xLlgkHjI#o#~?ldYSzh=N)(See?LC+ppNPGJmOh#)Mj~#DerIJ zN3X5P-3q!af)twn^G1(^yr4$VmJZw1(gt8B%1TA@6@f8v=!deupDMuRSTA8HjkbKI zWCo5jUl(>Pl6nXG@Yv^t_;K>jF1tEqHUh1wtRDs^bK zZVfp>cJg>VB0NZxMz+74H!^$5^EcGI(m)=hsiRY^{uTt%8Tv!v9d+&&G4G&iPCjS$ zlw}6Vm-y`nQ~pzOx9HWS=CVFSVWE-7L{93YDz_M!V#-Z1ij_ALU7p`|&4F*|$`4fi zQPQKBe;bx`k@mc&8|w|%^jj0EIY(Rz()c1hjMmdTpSs6PWhV9dX_OoOC>WFFoB3T# z(RKqKnM-)>P5ti(zHFZ_Q;jTITDXs}pH1$sU_6_~rzbkS#WMtvl8dYd$UjX8@HDmipZD}*<;1}+f=TtQeN|FGhQBXn>Eq=m#IorC~QJ4 zb30_cWu_pUIZ%9w-eRTaLA=zX?H+1s)TD4f^ui(4n(s0j1=V{o7WsLiOpj22)=((wn)6E*j9H(ilcD zyU9XU(qgZj@W86cDrf!D)`h|BrhzH)2>l7-PFrNPRK+{cUMD*%a?*^vn3J=1b`Q5k zHS7JFLSQ_*eUygU{TLr|;4^7>5|^Z^*olVJFFo!$GZm$HB$#FnD;v%xEs9<=3sI=P z(p5hW)+j6x&hs(PS-%*tezo_)c^lg#15g{^acYt(w;x)TIU>gI!cCSgxwB{9{V&ld zWz}4k9e4Shu9LiI{u@*!+~+>LXoA+0w~Slhpm^hJMiglyJi6riD<8_a-xjjs&kxLg zz|Ii;hn*7Kxm@}8%lyL2ZHrp~oZsetA3Mf9=h?;E;5!6#Q3I(I7?X4gI6qw9P`0kw z8+(nu`XJQ>r(o&iSe;U;o0XcwIisI;sg=btj#a3sp1-2JpYr*Cr<0ynJ*G&d{aoi2 zDDS0w0zUjh^*>X4XC*9zi&F(L5}d&K87Z4K#B%0Fah>HcCDraN(Ph){jdeHwL8J#I zad=kZxpxkQ7fa=OTWyO(qibGz<!8P)90 zc}z>rg48!^EaU+41$asZ2g1TI;I)dUlhu*A5d1A9DDsG;2FSxqNfId1QCK+5pQ>7=6ECXFekOD6o zLEa>+^dw1eL9P9ite-%oQV!Hd{lbR0K6(Ji{djek@#0fDXLmcnxQxarny(#yS0?cM-CLV@~$!eYL z2!%z|V;d|!mib{r)83WsQ|_eMIF>_a*5W5fG3dFaikr~aKO@h`LtQMD4AEs{TG~hR zrG6)F1(nr4DlJ!l;0&7T+}eRDKxwHQ=oebo@ssr5c@a=f)7Uc#tiFfdiC@2Dz;ZxZ z!+?3AqH&J)qM-1ah-ac7(zpc3YbXI31kW7#_WennzIlE=Y*py6p-H2yA3jS5qFYjo zyDeg2T0cFR6>@ofgtt^Har5wZscA_kjkAd!)9sPa{s+j4EM*#$Hh$23gtsaz*eTy& z-Lvs@0=(({XS^1jRb3kPc2|baWm%m z+sG&5!essQr>gBO!d$22U!nqppbp@=EQOt?2TeN@zATIln}r?@V6=Tybv{wne?QFT zMvnIq;`sZ`DoR>B84N#Q=Z2aHc%KRhH3&5{^Vi?Z`o0?48F)3&9C$ZGHl`b_7{jm3 zU7gKZs#tjoC+=O`2vi&BZrR-7w~nr$Ch67DAIATT-?ne} zSqtBwr{lkI^cB|A#qNvyO<$4xw&AQ#t0r}9vTF8YO@|h@_3MGnMAMqB{L(4U16ukC zMw^2P*)QK%4Yh0`v=O_@QbNn8VUhO}sFO4E)Fm73e$v-{yYEi!4pAwo+t^uWR7>*m zle_-Nl36{;v-^+P(NaX$z!;*Y?KebJ8dN{GDAYFi<(u6-y$m+*!Ta=;kG`1EW>>0} zI(SWwxoSPOr2G}$T@V!Xm5$H5C9Evs0(k=`xh$$)yA`UXI2`zP!sRf@LH|QLva8+1 zqodW~v1OoIvX@^Ay?YIBrE~jps#h5cLd{eJq{ri`ueX08+KjP(&Bm}O>%d!G%gpZ1 zQZ{0!t?_3z(t~lV;!Ylqzr6i0Al_uu_`9#I(f>6`geJp@rP8*q%r%1rb=vccNwj8} z&1BMLFG=;!lAEInn>gjRJQmK-AG@2$hk7i#v6|_8{ED!{1qqBSqRVuhmagm2^D#eD z{UAP~E9-4}{Sn4T+3Bz5SXtwXk($|^s0fT(m&{32chGHnI$7xJm+O&RM|DBYA-JHD z`kisb8~fRcghaYW8U72EotFLney6Gu^fmic5PLz#E~8zuB05#fZ>p4~M1fwb^>qlU zo(t8;XLL61jKQ@#F( zH2(M~sN}dkD9{vk0<{o)B;`8Bsl!6U&aP&%5yY_4Z^&FzIA#!-)vr>KG?RIyYL7UfprUOW^ z{naIiqw#bCE5g?#-v%Y!Y@NLE7hycGGyL35<&sU{Eo6rXUvE%!GR2}L#CXNR)qUFs zRNpmRZV103u=bBQU2OS$nIla&p&`LoyI| z@J-&s9BN0mcj|tJvFtvwZ1eRnUT>;&2KO`X)a5y+RW2bk

k&8`pBOEA-SJ=C>88 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