From 634b21d1bcabb2a5bd0fde7a171456a1e6387acd Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 1 Jun 2026 00:05:40 +0000 Subject: [PATCH 01/63] chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.35.2 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 512626b..a985b86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: rauthy: container_name: rauthy-dev - image: ghcr.io/sebadob/rauthy:0.35.1 + image: ghcr.io/sebadob/rauthy:0.35.2 environment: - LOCAL_TEST=true - SMTP_URL=mailcrab From 1fb6ba814ab92dd8180b44f8fd0ae1ed7cc552c7 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 1 Jun 2026 00:05:43 +0000 Subject: [PATCH 02/63] chore(deps): update dependency just to v1.51.0 --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index e72ed5f..cf63238 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.50.0 +just 1.51.0 From aaffd7b91c2330611eb951c3923c356a65b37288 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 3 Jun 2026 00:06:34 +0000 Subject: [PATCH 03/63] chore(deps): update postgres docker tag to v18.4 --- docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 37f9552..98d4053 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -33,7 +33,7 @@ services: restart: unless-stopped db-prod: - image: postgres:18.3-alpine + image: postgres:18.4-alpine container_name: mv-prod-db environment: POSTGRES_USER: postgres diff --git a/docker-compose.yml b/docker-compose.yml index 01a0bd2..aeb16ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ networks: services: db: - image: postgres:18.3-alpine + image: postgres:18.4-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres From 8429fb2b9ce0fedaf37ccb16115cf8f69039b638 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 3 Jun 2026 00:06:45 +0000 Subject: [PATCH 04/63] chore(deps): update mix dependencies to v1 --- mix.exs | 4 ++-- mix.lock | 36 +++++++++++++++++++----------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/mix.exs b/mix.exs index a510a7e..fa31c04 100644 --- a/mix.exs +++ b/mix.exs @@ -39,8 +39,8 @@ defmodule Mv.MixProject do [ {:tidewave, "~> 0.5", only: [:dev]}, {:sourceror, "~> 1.8", only: [:dev, :test]}, - {:live_debugger, "~> 0.8", only: [:dev]}, - {:ash_admin, "~> 0.14"}, + {:live_debugger, "~> 1.0", only: [:dev]}, + {:ash_admin, "~> 1.0"}, {:ash_postgres, "~> 2.0"}, {:ash_phoenix, "~> 2.0"}, {:ash, "~> 3.0"}, diff --git a/mix.lock b/mix.lock index 7dd592f..e39ebbc 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,6 @@ %{ - "ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"}, - "ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"}, + "ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"}, + "ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"}, "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"}, @@ -11,18 +11,18 @@ "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"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, + "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, + "cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [: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", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, - "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, + "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, - "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, @@ -32,10 +32,11 @@ "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, + "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, @@ -44,7 +45,7 @@ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, + "igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"}, "imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, @@ -52,22 +53,23 @@ "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "live_debugger": {:hex, :live_debugger, "0.8.0", "02545b5accdf42f48aa9bddabdd7574ddf532d5aa0cb0270d7e35031bc184286", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4ecdec3c4267e665afd2266e3cb86239dd457f8c8fc4e63de6e150a2a7665920"}, + "live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"}, "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [: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", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"}, + "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [: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", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [: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", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [: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", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"}, "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"}, @@ -78,15 +80,15 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, - "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, - "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, + "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"}, - "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, + "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, @@ -96,13 +98,13 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "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"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, } From 7a0dff926a7961c6db44c53f37ff87cc67e5689a Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 4 Jun 2026 00:06:08 +0000 Subject: [PATCH 05/63] chore(deps): update mix dependencies --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index a510a7e..42ce494 100644 --- a/mix.exs +++ b/mix.exs @@ -47,7 +47,7 @@ defmodule Mv.MixProject do {:bcrypt_elixir, "~> 3.0"}, {:ash_authentication, "~> 4.9"}, {:ash_authentication_phoenix, "~> 2.10"}, - {:igniter, "~> 0.7", only: [:dev, :test]}, + {:igniter, "~> 0.8", only: [:dev, :test]}, {:phoenix, "~> 1.8.0-rc.4", override: true}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, From 7f3b610937790bfd9c55231bfa50c599a8966b6d Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 4 Jun 2026 15:45:00 +0200 Subject: [PATCH 06/63] chore: update mix.lock --- mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.lock b/mix.lock index e39ebbc..60cfef5 100644 --- a/mix.lock +++ b/mix.lock @@ -11,7 +11,7 @@ "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"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, @@ -98,7 +98,7 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, From 065ecdfb2c518c3861cc72ff560fe0005ac102d2 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 4 Jun 2026 16:18:24 +0200 Subject: [PATCH 07/63] docs(opencode): add landingURL to publiccode.yml --- publiccode.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/publiccode.yml b/publiccode.yml index 2a4a33b..f7394aa 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -1,6 +1,7 @@ publiccodeYmlVersion: "0.2" name: Mila url: "https://git.local-it.org/local-it/mitgliederverwaltung" +landingURL: "https://local-it.org" softwareVersion: "1.2.0" releaseDate: "2026-05-08" developmentStatus: beta From 8e5dd7e4c68418f9fe5d44d0fe0234261834b897 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 4 Jun 2026 16:40:05 +0200 Subject: [PATCH 08/63] feat(web): add chevron affordance and scope-badge slot to dropdown triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropdown openers were visually indistinguishable from ordinary buttons. A trailing chevron now marks every dropdown trigger — both the shared dropdown_menu component and the bespoke member-filter trigger — and an optional badge slot lets a trigger show a status indicator beside its label. --- lib/mv_web/components/core_components.ex | 5 +++++ .../components/member_filter_component.ex | 1 + ...eld_visibility_dropdown_component_test.exs | 8 +++++++ .../member_filter_component_test.exs | 14 +++++++++++++ .../components/sort_header_component_test.exs | 21 +++++++++++++++---- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 465d41a..13c69a8 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -464,6 +464,9 @@ defmodule MvWeb.CoreComponents do slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)" + slot :trigger_badge, + doc: "Optional badge rendered in the trigger after the label (e.g. a scope badge)" + def dropdown_menu(assigns) do menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" @@ -498,6 +501,8 @@ defmodule MvWeb.CoreComponents do <.icon name={@icon} /> <% end %> {@button_label} + {render_slot(@trigger_badge)} + <.icon name="hero-chevron-down" class="size-4" />
    {@member_count} + <.icon name="hero-chevron-down" class="size-4" /> - <%= if can?(@current_user, :create, Mv.Membership.Member) do %> - <.link patch={~p"/members/new"}>New Member - <% end %> - - - <%= if can?(@current_user, :update, @member) do %> - <.button>Edit - <% end %> - - - <%= if can_access_page?(@current_user, "/admin/roles") do %> - <.link navigate="/admin/roles">Manage Roles - <% end %> - - ## Performance - - All checks are pure function calls using the hardcoded PermissionSets module. - No database queries, < 1 microsecond per check. - """ - - alias Mv.Authorization.PermissionSets +**Public functions:** - @doc """ - Checks if user has permission for an action on a resource (atom). - - ## Examples - - iex> admin = %{role: %{permission_set_name: "admin"}} - iex> can?(admin, :create, Mv.Membership.Member) - true - - iex> mitglied = %{role: %{permission_set_name: "own_data"}} - iex> can?(mitglied, :create, Mv.Membership.Member) - false - """ - @spec can?(map() | nil, atom(), atom()) :: boolean() - def can?(nil, _action, _resource), do: false - - def can?(user, action, resource) when is_atom(action) and is_atom(resource) do - with %{role: %{permission_set_name: ps_name}} <- user, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - resource_name = get_resource_name(resource) - - Enum.any?(permissions.resources, fn perm -> - perm.resource == resource_name and - perm.action == action and - perm.granted - end) - else - _ -> false - end - end +- `can?/3` (resource atom) — `can?(user, action, Mv.Membership.Member)`: true iff the user's + permission set grants `action` on that resource (any scope). +- `can?/3` (record struct) — `can?(user, action, %Member{})`: finds the matching permission, + then applies the scope check against the record: + - `:all` → always true + - `:own` → `record.id == user.id` + - `:linked` → resource-specific: Member checks `record.user_id == user.id`; CustomFieldValue + traverses `record.member.user_id == user.id` (member must be preloaded), with a `user_id` + fallback for other resources. +- `can_access_page?/2` — matches the path against the permission set's `pages` list using the + same rules as the plug: `*` wildcard, exact match, or dynamic segment match (`:id`). - @doc """ - Checks if user has permission for an action on a specific record (struct). - - Applies scope checking: - - :own - record.id == user.id - - :linked - record.user_id == user.id (or traverses relationships) - - :all - always true - - ## Examples - - iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}} - iex> member = %Member{id: "member-456", user_id: "user-123"} - iex> can?(user, :update, member) - true - - iex> other_member = %Member{id: "member-789", user_id: "other-user"} - iex> can?(user, :update, other_member) - false - """ - @spec can?(map() | nil, atom(), struct()) :: boolean() - def can?(nil, _action, _record), do: false - - def can?(user, action, %resource{} = record) when is_atom(action) do - with %{role: %{permission_set_name: ps_name}} <- user, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - resource_name = get_resource_name(resource) - - # Find matching permission - matching_perm = Enum.find(permissions.resources, fn perm -> - perm.resource == resource_name and - perm.action == action and - perm.granted - end) - - case matching_perm do - nil -> false - perm -> check_scope(perm.scope, user, record, resource_name) - end - else - _ -> false - end - end +All three return **false** for a nil user, a user without a role, or an invalid +`permission_set_name` (graceful, fail-closed — no crash). The scope/page-matching logic mirrors +`HasPermission` and `CheckPagePermission` exactly; resource names come from +`Module.split() |> List.last()`. - @doc """ - Checks if user can access a specific page. - - ## Examples - - iex> admin = %{role: %{permission_set_name: "admin"}} - iex> can_access_page?(admin, "/admin/roles") - true - - iex> mitglied = %{role: %{permission_set_name: "own_data"}} - iex> can_access_page?(mitglied, "/members") - false - """ - @spec can_access_page?(map() | nil, String.t()) :: boolean() - def can_access_page?(nil, _page_path), do: false - - def can_access_page?(user, page_path) do - with %{role: %{permission_set_name: ps_name}} <- user, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - page_matches?(permissions.pages, page_path) - else - _ -> false - end - end +### UI Usage Pattern - # Check if scope allows access to record - defp check_scope(:all, _user, _record, _resource_name), do: true - - defp check_scope(:own, user, record, _resource_name) do - record.id == user.id - end - - defp check_scope(:linked, user, record, resource_name) do - case resource_name do - "Member" -> - # Direct relationship: member.user_id - Map.get(record, :user_id) == user.id - - "CustomFieldValue" -> - # Need to traverse: custom_field_value.member.user_id - # Note: In UI, custom_field_value should have member preloaded - case Map.get(record, :member) do - %{user_id: member_user_id} -> member_user_id == user.id - _ -> false - end - - _ -> - # Fallback: check user_id - Map.get(record, :user_id) == user.id - end - end - - # Check if page path matches any allowed pattern - defp page_matches?(allowed_pages, requested_path) do - Enum.any?(allowed_pages, fn pattern -> - cond do - pattern == "*" -> true - pattern == requested_path -> true - String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path) - true -> false - end - end) - end - - # Match dynamic route pattern - defp match_pattern?(pattern, path) do - pattern_segments = String.split(pattern, "/", trim: true) - path_segments = String.split(path, "/", trim: true) - - if length(pattern_segments) == length(path_segments) do - Enum.zip(pattern_segments, path_segments) - |> Enum.all?(fn {pattern_seg, path_seg} -> - String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg - end) - else - false - end - end - - # Extract resource name from module - defp get_resource_name(resource) when is_atom(resource) do - resource |> Module.split() |> List.last() - end -end -``` - -### Import in mv_web.ex - -Make helpers available to all LiveViews: - -```elixir -defmodule MvWeb do - # ... - - def html_helpers do - quote do - # ... existing helpers ... - - # Authorization helpers - import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] - end - end - - # ... -end -``` - -### UI Examples - -**Navbar with conditional links:** - -```heex - - -``` - -**Index page with conditional "New" button:** - -```heex - - - - - - <%= for member <- @members do %> - - - - - <% end %> -
    <%= member.name %> - - <%= if can?(@current_user, :update, member) do %> - <.link patch={~p"/members/#{member.id}/edit"}>Edit - <% end %> - - - <%= if can?(@current_user, :destroy, member) do %> - <.button phx-click="delete" phx-value-id={member.id}>Delete - <% end %> -
    -``` - -**Show page with conditional edit button:** - -```heex - -
    -

    <%= @member.name %>

    - -
    -
    Email
    -
    <%= @member.email %>
    - -
    Address
    -
    <%= @member.address %>
    -
    - - - <%= if can?(@current_user, :update, @member) do %> - <.link patch={~p"/members/#{@member.id}/edit"} class="btn-primary"> - Edit Member - - <% end %> -
    -``` +LiveView templates gate elements with the helpers: page-level links use +`can_access_page?(@current_user, path)` (e.g. the `/members` link and the admin +dropdown), resource-level buttons use `can?(@current_user, :create, Resource)` +(e.g. "New Member"), and per-record buttons use `can?(@current_user, action, record)` +(e.g. Edit/Delete in a member row, or the edit button on a show page). The navbar has +since been replaced by the sidebar (`lib/mv_web/components/layouts/sidebar.ex`). --- @@ -1807,7 +1003,7 @@ end - All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) grant `User.update :own` - Even a user with `read_only` (read-only for member data) can update their own credentials -**Important:** UPDATE is NOT an unverrückbarer Spezialfall (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details. +**Important:** UPDATE is NOT an immovable special case (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details. ### 1a. User Credentials: Why read_only Can Still Update @@ -1832,209 +1028,33 @@ end - **Clarity:** The name "read_only" refers to member data, not user credentials - **Maintainability:** Easy to see what each role can do in PermissionSets module -**Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional - UPDATE is controlled by PermissionSets, not hardcoded. - -**Example:** -```elixir -# In PermissionSets.get_permissions(:read_only) -resources: [ - # User: Can read/update own credentials only - # IMPORTANT: "read_only" refers to member data, NOT user credentials. - # All permission sets grant User.update :own to allow password changes. - %{resource: "User", action: :read, scope: :own, granted: true}, - %{resource: "User", action: :update, scope: :own, granted: true}, - - # Member: Can read all members, no modifications - %{resource: "Member", action: :read, scope: :all, granted: true}, - # Note: No Member.update permission - this is the "read_only" part -] -``` +**Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional — UPDATE is controlled by PermissionSets, not hardcoded. Every set's `get_permissions/...` therefore carries both `%{resource: "User", action: :read, scope: :own}` and `%{... action: :update, scope: :own}`; the "read_only" label applies to member data (no `Member :update`), not credentials. ### 2. Linked Member Email Editing -**Requirement:** Only administrators can edit the email of a member that is linked to a user (has `user_id` set). This prevents breaking email synchronization. +**Requirement:** For a member linked to a user account (has a linked user), only administrators **or the linked user themselves** can change the email. This prevents breaking the Member↔User email synchronization while still letting a user update their own email. -**Implementation:** - -Custom validation in `Member` resource: - -```elixir -defmodule Mv.Membership.Member do - use Ash.Resource, ... - - validations do - # Only run when email is being changed - validate changing(:email), on: :update do - validate &validate_linked_member_email_change/2 - end - end - - defp validate_linked_member_email_change(changeset, _context) do - member = changeset.data - actor = changeset.context[:actor] - - # If member is not linked to user, allow change - if is_nil(member.user_id) do - :ok - else - # Member is linked - check if actor is admin - if has_admin_permission?(actor) do - :ok - else - {:error, "Only administrators can change email for members linked to user accounts"} - end - end - end - - defp has_admin_permission?(nil), do: false - - defp has_admin_permission?(actor) do - with %{role: %{permission_set_name: ps_name}} <- actor, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - # Check if actor has User.update permission with scope :all (admin privilege) - Enum.any?(permissions.resources, fn perm -> - perm.resource == "User" and - perm.action == :update and - perm.scope == :all and - perm.granted - end) - else - _ -> false - end - end -end -``` - -**Why this is needed:** -- Member email and User email are kept in sync -- If a non-admin changes linked member email, it could create inconsistency -- Validation runs AFTER policy check, so normal_user can update member -- But validation blocks email field specifically if member is linked +**Implementation:** The `Mv.Membership.Member.Validations.EmailChangePermission` module (registered as `validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]`) runs **after** the policy check (so a `normal_user` may update the member but is still blocked on the email field). It only acts when the email is changing: if the member has no linked user it allows the change; otherwise it allows the change when the actor is admin (`Mv.Authorization.Actor.admin?/1`, which also treats the system actor as admin) **or** owns the linked member (`actor.member_id == member.id`), and otherwise returns `{:error, "Only administrators or the linked user can change the email for members linked to users"}`. A missing actor is not allowed. ### 3. System Role Protection -**Requirement:** The "Mitglied" role cannot be deleted because it's the default role for all users. +**Requirement:** The "Mitglied" role cannot be deleted (it's the default role for all users). -**Implementation:** - -Flag + validation in `Role` resource: - -```elixir -defmodule Mv.Authorization.Role do - use Ash.Resource, ... - - attributes do - # ... - attribute :is_system_role, :boolean, default: false - end - - validations do - validate action(:destroy) do - validate fn _changeset, %{data: role} -> - if role.is_system_role do - {:error, "Cannot delete system role. System roles are required for the application to function."} - else - :ok - end - end - end - end -end -``` - -**Seeds set the flag:** - -```elixir -%{ - name: "Mitglied", - permission_set_name: "own_data", - is_system_role: true # <-- Protected! -} -``` - -**UI hides delete button:** - -```heex -<%= if can?(@current_user, :destroy, role) and not role.is_system_role do %> - <.button phx-click="delete">Delete -<% end %> -``` +**Implementation:** The `Role` resource has an `is_system_role` boolean (default false); a destroy validation returns `{:error, "Cannot delete system role. ..."}` when `role.is_system_role` is true. Seeds set `is_system_role: true` only on "Mitglied". The UI also hides the delete button: `can?(@current_user, :destroy, role) and not role.is_system_role`. ### 4. User Without Role (Edge Case) -**Requirement:** Users without a role should be denied all access (except logout). +**Requirement:** Users without a role are denied all access (except logout). -**Implementation:** +**Implementation:** Seeds assign "Mitglied" to all users where `role_id` is nil. At runtime every check handles a missing role gracefully — `HasPermission` returns `{:error, :no_role}` (and the UI helpers/plug return false) rather than crashing. -**Default Assignment:** Seeds assign "Mitglied" role to all existing users - -```elixir -# In authorization_seeds.exs -mitglied_role = Ash.get!(Role, name: "Mitglied") -users_without_role = Ash.read!(User, filter: expr(is_nil(role_id))) - -Enum.each(users_without_role, fn user -> - Ash.update!(user, %{role_id: mitglied_role.id}) -end) -``` - -**Runtime Handling:** All authorization checks handle missing role gracefully - -```elixir -# In HasPermission check -def match?(actor, %{resource: resource, action: action}, _opts) do - with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, - # ... - else - %{role: nil} -> - {:error, :no_role} # User has no role -> forbidden - - _ -> - {:error, :no_permission} - end -end -``` - -**Result:** User with no role sees empty UI, cannot access pages, gets forbidden on all actions. +**Result:** A user with no role sees an empty UI, cannot access pages, and is forbidden on all actions. ### 5. Invalid permission_set_name (Edge Case) **Requirement:** If a role has an invalid `permission_set_name`, fail gracefully without crashing. -**Implementation:** - -**Prevention:** Validation on Role resource - -```elixir -validations do - validate attribute(:permission_set_name) do - validate fn _changeset, value -> - if PermissionSets.valid_permission_set?(value) do - :ok - else - {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"} - end - end - end -end -``` - -**Runtime Handling:** All lookups check validity - -```elixir -# In PermissionSets module -def permission_set_name_to_atom(name) when is_binary(name) do - atom = String.to_existing_atom(name) - if valid_permission_set?(atom) do - {:ok, atom} - else - {:error, :invalid_permission_set} - end -rescue - ArgumentError -> {:error, :invalid_permission_set} -end -``` +**Implementation:** Prevented up front by a `Role` attribute validation that rejects any value not in `PermissionSets.all_permission_sets/0` (`"Invalid permission set name. Must be one of: ..."`). At runtime, every lookup goes through `permission_set_name_to_atom/1`, which rescues the `ArgumentError` from `String.to_existing_atom/1` (see PermissionSets above), so an invalid name yields `{:error, :invalid_permission_set}`. **Result:** Invalid `permission_set_name` → authorization fails → forbidden (safe default). @@ -2054,158 +1074,41 @@ Users and Members are separate entities that can be linked. Special rules: - **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. - **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only. -### Approach: Separate Ash Actions +### Approach: One Pair of Actions Plus an Admin-Only `:user` Argument -We use **different Ash actions** to enforce different policies: - -1. **`create_member_for_self`** - User creates member and links to themselves -2. **`create_member`** - Admin creates member for any user (or unlinked) -3. **`link_member_to_user`** - Admin links existing member to user -4. **`unlink_member_from_user`** - Admin removes user link -5. **`update`** - Standard update (cannot change `user_id`) +Linking is **not** modelled as separate per-operation actions. The `Mv.Membership.Member` +resource (`lib/membership/member.ex`) exposes the actions `create_member`, `update_member`, +`set_vereinfacht_contact_id`, `search`, and `available_for_linking` (plus the default +`:read`/`:destroy`). Linking and unlinking happen through the optional **`:user` argument** on +`create_member` / `update_member`, not through dedicated `link_*`/`unlink_*` actions. (`user_id` +is deliberately **not** in the accept list, so the foreign key cannot be set directly.) ### Implementation -```elixir -defmodule Mv.Membership.Member do - use Ash.Resource, ... - - actions do - # SELF-SERVICE: User creates member and links to self - create :create_member_for_self do - description "User creates a new member and links it to their own account" - - accept [:name, :email, :address, ...] # All fields except user_id - - # Automatically set user_id to actor - change set_attribute(:user_id, actor(:id)) - - # Prevent creating multiple members for same user (optional business rule) - validate fn changeset, _context -> - actor_id = get_change(changeset, :user_id) - - case Ash.read(Member, filter: expr(user_id == ^actor_id)) do - {:ok, []} -> :ok # No existing member, allow - {:ok, [_member | _]} -> {:error, "You already have a member profile"} - {:error, _} -> :ok - end - end - end +The user–member link is governed by two facts about `create_member` / `update_member`: - # ADMIN: Create member with optional user link - create :create_member do - description "Admin creates a new member, optionally linked to a user" - - accept [:name, :email, :address, ..., :user_id] # Admin can set user_id - end +- The `:user` argument drives the relationship via `manage_relationship(:user, ...)` with + `on_lookup: :relate`, `on_no_match: :error`, `on_match: :error`, and **`on_missing: :ignore`**. + Because of `on_missing: :ignore`, **omitting** `:user` leaves the link unchanged (no "unlink by + omission"); unlink is explicit (`user: nil`/`user: %{}`), handled on update via the + `UnrelateUserWhenArgumentNil` change. +- Whether the `:user` argument may be used at all is gated by the policy check + `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` (`forbid_if` before + `authorize_if HasPermission` on `action_type([:create, :update])`). It forbids the action for a + non-admin whenever the `:user` argument **key is present** (any value), so only admins may set or + change the link. Non-admins can still create/update members as long as they do not pass `:user`. - # ADMIN: Link existing member to user - update :link_member_to_user do - description "Admin links an existing member to a user account" - - accept [:user_id] - - validate fn changeset, _context -> - member = changeset.data - - # Cannot link if already linked - if is_nil(member.user_id) do - :ok - else - {:error, "Member is already linked to a user"} - end - end - end +Self-service ("a user creates a member and is linked to it") is handled on the **User** side, not +by a special Member action: the admin-only `update_user` action takes a `:member` argument for +link/unlink (see Enforcement above), and the UI gates the linking controls on admin status. - # ADMIN: Remove user link from member - update :unlink_member_from_user do - description "Admin removes user link from member" - - change set_attribute(:user_id, nil) - end +### Why This Design? - # STANDARD UPDATE: Cannot change user_id - update :update do - description "Update member data (cannot change user link)" - - accept [:name, :email, :address, ...] # user_id NOT in accept list - end - end - - policies do - # Self-service member creation - policy action(:create_member_for_self) do - description "Any authenticated user can create member for themselves" - authorize_if actor_present() - end - - # Admin-only actions - policy action([:create_member, :link_member_to_user, :unlink_member_from_user]) do - description "Only admin can manage user-member links" - authorize_if Mv.Authorization.Checks.HasPermission - end - - # Standard actions (regular permission check) - policy action([:read, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.HasPermission - end - end -end -``` - -### UI Examples - -**User Self-Service:** - -```heex - -<%= if is_nil(@current_user.member_id) do %> - <.link navigate="/members/new_for_self"> - Create My Member Profile - -<% end %> - - -<.simple_form for={@form} phx-submit="create_for_self"> - <.input field={@form[:name]} label="Name" /> - <.input field={@form[:email]} label="Email" /> - <.input field={@form[:address]} label="Address" /> - - - - <:actions> - <.button>Create My Profile - - -``` - -**Admin Interface:** - -```heex - -<%= if can?(@current_user, :link_member_to_user, @member) do %> - <%= if is_nil(@member.user_id) do %> - - <.form for={@link_form} phx-submit="link_to_user"> - <.input field={@link_form[:user_id]} type="select" label="Link to User" options={@users} /> - <.button>Link to User - - <% else %> - - <.button phx-click="unlink_from_user" phx-value-id={@member.id}> - Unlink from User (<%= @member.user.email %>) - - <% end %> -<% end %> -``` - -### Why Separate Actions? - -✅ **Clear Intent:** Action name communicates what's happening -✅ **Precise Policies:** Different policies for different operations -✅ **Better UX:** Separate UI flows for self-service vs. admin -✅ **Testable:** Each action can be tested independently -✅ **Idiomatic Ash:** Uses Ash's action system as designed +Keeping the link on a single `:user` argument (rather than a fan-out of `link_*`/`unlink_*` +actions) means there is exactly one create and one update path to reason about, the +admin-only rule lives in one reusable policy check (`ForbidMemberUserLinkUnlessAdmin`) instead of +being duplicated per action, and `user_id` can never be mass-assigned because it is not accepted — +only the argument-driven relationship management can change it. --- @@ -2244,58 +1147,12 @@ def get_permissions(:read_only) do end ``` -**Read Filtering via Ash Calculations:** +**Read filtering** via an Ash calculation that takes `allowed_fields` from PermissionSets and +`Map.take/2`s each record to those fields. **Write protection** via an update validation that +diffs `Map.keys(changeset.attributes)` against the allowed write fields and returns +`{:error, "You do not have permission to modify: ..."}` for any forbidden field. -```elixir -defmodule Mv.Membership.Member do - calculations do - calculate :filtered_fields, :map do - calculate fn members, context -> - actor = context[:actor] - - # Get allowed fields from PermissionSets - allowed_fields = get_allowed_read_fields(actor, "Member") - - # Filter fields - Enum.map(members, fn member -> - Map.take(member, allowed_fields) - end) - end - end - end -end -``` - -**Write Protection via Custom Validations:** - -```elixir -validations do - validate on: :update do - validate fn changeset, context -> - actor = context[:actor] - changed_fields = Map.keys(changeset.attributes) - - # Get allowed fields from PermissionSets - allowed_fields = get_allowed_write_fields(actor, "Member") - - # Check if any forbidden field is being changed - forbidden = Enum.reject(changed_fields, &(&1 in allowed_fields)) - - if Enum.empty?(forbidden) do - :ok - else - {:error, "You do not have permission to modify: #{Enum.join(forbidden, ", ")}"} - end - end - end -end -``` - -**Benefits:** -- ✅ No database schema changes -- ✅ Still uses hardcoded PermissionSets -- ✅ Granular control over sensitive fields -- ✅ Clear error messages +**Benefits:** No database schema changes, still uses hardcoded PermissionSets, granular control over sensitive fields, clear error messages. **Estimated Effort:** 2-3 weeks @@ -2368,15 +1225,9 @@ defmodule Mv.Authorization.PermissionCache do end ``` -**Benefits:** -- ✅ Runtime permission configuration -- ✅ More flexible than hardcoded -- ✅ Can add new permission sets without code changes +**Benefits:** Runtime permission configuration, more flexible than hardcoded, can add new permission sets without code changes. -**Trade-offs:** -- ⚠️ More complex (DB queries, cache, invalidation) -- ⚠️ Slightly slower (mitigated by cache) -- ⚠️ More testing needed +**Trade-offs:** More complex (DB queries, cache, invalidation), slightly slower (mitigated by cache), more testing needed. **Estimated Effort:** 3-4 weeks @@ -2393,12 +1244,24 @@ See [Migration Strategy](#migration-strategy) for detailed migration plan. ### Three-Phase Approach -**Phase 1: MVP (2-3 weeks) - CURRENT** +**Phase 1: MVP (2-3 weeks) - CURRENT** (shipped 2026-01-08, PR #346, closes #345) - Hardcoded PermissionSets module - `HasPermission` check reads from module - Role table with `permission_set_name` string - Zero DB queries for permission checks +**What's NOT in MVP (deferred to Phase 3):** +- `PermissionSetResource` database table +- `PermissionSetPage` database table +- ETS Permission Cache +- Database-backed dynamic permissions / runtime permission editing + +**MVP DB migration & rollback.** Issue #1 adds a single migration: create the `roles` table (`name` unique, `permission_set_name`, `is_system_role`, timestamps; indexes on `name` and `permission_set_name`) and add nullable `users.role_id` FK (`ON DELETE RESTRICT`) with its index. The migration is additive only — no existing table is modified destructively. The 5 roles are created by `priv/repo/seeds_bootstrap.exs`, and the `assign_mitglied_role_to_existing_users` migration assigns "Mitglied" to users without a role. + +Rollback options, in order of escalation: +1. **DB rollback:** the `down` migration drops the `users.role_id` index, removes the `role_id` column, and drops the `roles` table — `mix ecto.rollback --step 1`. Existing tables are untouched. +2. **Code rollback:** revert the commit and redeploy the previous version. + **Phase 2: Field-Level (2-3 weeks) - FUTURE** - Extend PermissionSets with `:fields` key - Ash Calculations for read filtering @@ -2432,60 +1295,13 @@ See [Migration Strategy](#migration-strategy) for detailed migration plan. ### Migration from MVP to Phase 3 -**Step-by-step:** - -1. **Create DB Tables** (1 day) - - Run migrations for `permission_sets`, `permission_set_resources`, `permission_set_pages` - - Add indexes - -2. **Seed from PermissionSets Module** (1 day) - - Script that reads from `PermissionSets.get_permissions/1` - - Inserts into new tables - - Verify data integrity - -3. **Create HasResourcePermission Check** (2 days) - - New check that queries DB - - Same logic as `HasPermission` but different data source - - Comprehensive tests - -4. **Implement ETS Cache** (2 days) - - Cache module - - Cache invalidation on updates - - Performance tests - -5. **Update Policies** (3 days) - - Replace `HasPermission` with `HasResourcePermission` in all resources - - Test each resource thoroughly - -6. **Update UI Helpers** (1 day) - - Modify `MvWeb.Authorization` to query DB - - Use cache for performance - -7. **Update Page Plug** (1 day) - - Modify `CheckPagePermission` to query DB - - Use cache - -8. **Integration Testing** (3 days) - - Full user journey tests - - Performance testing - - Load testing - -9. **Deploy to Staging** (1 day) - - Feature flag approach - - Run both systems in parallel - - Compare results - -10. **Deploy to Production** (1 day) - - Gradual rollout - - Monitor performance - - Rollback plan ready - -11. **Cleanup** (1 day) - - Remove old `HasPermission` check - - Remove `PermissionSets` module - - Update documentation - -**Total:** ~3-4 weeks +Sequence (~3-4 weeks): create the three permission tables + indexes; seed them from +`PermissionSets.get_permissions/1`; add a `HasResourcePermission` check that queries the DB +(same logic as `HasPermission`, different data source) backed by the ETS cache with +invalidation on update; swap `HasPermission` → `HasResourcePermission` in all resources and +point the UI helper + page plug at the DB/cache; integration + performance/load test; deploy +behind the feature flag (run both systems in parallel to compare) then gradually to production; +finally remove the old `HasPermission` check and `PermissionSets` module. --- @@ -2547,28 +1363,9 @@ See [Migration Strategy](#migration-strategy) for detailed migration plan. ### Audit Logging (Future) -**Not in MVP, but planned:** - -```elixir -defmodule Mv.Authorization.AuditLog do - def log_authorization_failure(actor, resource, action, reason) do - Ash.create!(AuditLog, %{ - user_id: actor.id, - resource: inspect(resource), - action: action, - outcome: "forbidden", - reason: reason, - ip_address: get_ip_address(), - timestamp: DateTime.utc_now() - }) - end -end -``` - -**Benefits:** -- Track suspicious authorization attempts -- Compliance (GDPR requires access logs) -- Debugging production issues +Not in MVP, but planned: persist authorization failures (user id, resource, action, outcome, +reason, IP, timestamp) to an `AuditLog` resource — for tracking suspicious attempts, GDPR access +logs, and production debugging. Currently failures are only `Logger`-logged. --- @@ -2577,11 +1374,9 @@ end ### Glossary - **Permission Set:** Named collection of permissions (e.g., "admin", "read_only") -- **Role:** Database entity linking users to permission sets -- **Scope:** Range of records permission applies to (:own, :linked, :all) +- **Role:** Database entity linking users to a permission set; **system role** cannot be deleted (`is_system_role=true`) +- **Scope:** Range of records a permission applies to (`:own`, `:linked`, `:all`) - **Actor:** Currently authenticated user in Ash context -- **Policy:** Ash authorization rule on a resource -- **System Role:** Role that cannot be deleted (is_system_role=true) - **Special Case:** Authorization rule that takes precedence over general permissions ### Resource Name Mapping @@ -2614,54 +1409,9 @@ These strings must match exactly in `PermissionSets` module. | User without role | Access denied everywhere | Seeds assign default role, runtime checks handle gracefully | | Invalid permission_set_name | Access denied | Validation on Role, runtime safety checks | | System role deletion | Forbidden | Validation prevents deletion if `is_system_role=true` | -| Linked member email | Admin-only edit | Custom validation in Member resource | +| Linked member email | Admin or linked user may edit | `Member.Validations.EmailChangePermission` | | Own credentials | Always accessible | Special policy before general check | -### Testing Checklist - -**For Each Resource:** -- [ ] All 5 roles tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) -- [ ] All actions tested (read, create, update, destroy) -- [ ] All scopes tested (own, linked, all) -- [ ] Special cases tested -- [ ] Edge cases tested (nil role, invalid permission_set_name) - -**For UI:** -- [ ] Buttons/links show/hide correctly per role -- [ ] Page access controlled per role -- [ ] No broken links (all visible links are accessible) - -**Integration:** -- [ ] One complete user journey per role -- [ ] Cross-resource scenarios (e.g., Member -> CustomFieldValue) -- [ ] Special cases in context (e.g., linked member email during full edit flow) - -### Useful Commands - -```bash -# Run all authorization tests -mix test test/mv/authorization - -# Run integration tests -mix test test/integration - -# Run with coverage -mix test --cover - -# Generate migrations -mix ash.codegen - -# Run seeds -mix run priv/repo/seeds/authorization_seeds.exs - -# Check permission for user in IEx -iex> user = Mv.Accounts.get_user!("user-id") -iex> MvWeb.Authorization.can?(user, :create, Mv.Membership.Member) - -# Check page access in IEx -iex> MvWeb.Authorization.can_access_page?(user, "/members/new") -``` - --- ## Authorization Bootstrap Patterns diff --git a/docs/roles-and-permissions-implementation-plan.md b/docs/roles-and-permissions-implementation-plan.md index 95db031..74b8705 100644 --- a/docs/roles-and-permissions-implementation-plan.md +++ b/docs/roles-and-permissions-implementation-plan.md @@ -1,1663 +1,39 @@ -# Roles and Permissions - Implementation Plan (MVP) +# Roles and Permissions - Implementation Record (MVP) -**Version:** 2.0 (Clean Rewrite) -**Date:** 2025-01-13 -**Last Updated:** 2026-01-13 -**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345) -**Related Documents:** -- [Overview](./roles-and-permissions-overview.md) - High-level concepts -- [Architecture](./roles-and-permissions-architecture.md) - Technical specification +**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345) +**Related:** [Overview](./roles-and-permissions-overview.md) · [Architecture](./roles-and-permissions-architecture.md) ---- +> Historical record of how the MVP (Phase 1) of the hardcoded Roles & Permissions +> system was built. The architecture document is the canonical design reference; +> the DB migration/rollback steps and the "What's NOT in MVP" boundary now live in +> its [Migration Strategy](./roles-and-permissions-architecture.md#migration-strategy) +> section. -## Table of Contents +## How the MVP was built -- [Executive Summary](#executive-summary) -- [MVP Scope](#mvp-scope) -- [Implementation Strategy](#implementation-strategy) -- [Issue Breakdown](#issue-breakdown) - - [Sprint 1: Foundation](#sprint-1-foundation-week-1) - - [Sprint 2: Policies](#sprint-2-policies-week-2) - - [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3) - - [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4) -- [Dependencies & Parallelization](#dependencies--parallelization) -- [Testing Strategy](#testing-strategy) -- [Migration & Rollback](#migration--rollback) -- [Risk Management](#risk-management) +The MVP shipped as **PR #346 (closes #345)** across four week-sized sprints, built +test-first (TDD) with the work split into 15 issues: ---- +- **Sprint 1 — Foundation:** `Role` Ash resource + `users.role_id` FK (#1); hardcoded + `PermissionSets` module with the 4 sets `own_data`/`read_only`/`normal_user`/`admin` (#2); + Role CRUD admin LiveViews (#3). +- **Sprint 2 — Policies:** `HasPermission` custom Ash policy check (#6); resource policies + for Member (#7), User (#8), CustomFieldValue (#9), CustomField (#10); page-permission + router plug (#11). Issues #7–#11 ran in parallel after #6. +- **Sprint 3 — Special cases & seeds:** linked-member email validation (#12); role seed + data + default-role assignment (#13). +- **Sprint 4 — UI & integration:** `MvWeb.Authorization` UI helper (`can?/3`, + `can_access_page?/2`) (#14); admin role-management UI (#15); applying UI authorization + to existing LiveViews + navbar (#16); per-role integration journey tests (#17). -## Executive Summary +The 5 seeded roles map to permission sets as: Mitglied → own_data (system role), +Vorstand → read_only, Kassenwart → normal_user, Buchhaltung → read_only, Admin → admin. -### Overview +Issues #4, #5, #18 (DB-backed permission tables and ETS cache) were intentionally +**not** built — see "What's NOT in MVP" in the architecture document. -This document defines the implementation plan for the **MVP (Phase 1)** of the Roles and Permissions system using **hardcoded Permission Sets** in an Elixir module. - -**Key Characteristics:** -- **15 issues total** (Issues #1-3, #6-17) -- **2-3 weeks duration** -- **180+ tests** -- **Test-Driven Development (TDD)** throughout -- **No database tables for permissions** - only `roles` table -- **Zero performance concerns** - all permission checks are in-memory function calls - -### What's NOT in MVP - -**Deferred to Phase 3 (Future):** -- Issue #4: `PermissionSetResource` database table -- Issue #5: `PermissionSetPage` database table -- Issue #18: ETS Permission Cache -- Database-backed dynamic permissions - -### The Four Permission Sets - -Hardcoded in `Mv.Authorization.PermissionSets` module: - -1. **own_data** - User can only access their own data (default for "Mitglied") -2. **read_only** - Read access to all members/custom field values (for "Vorstand", "Buchhaltung") -3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart") -4. **admin** - Unrestricted access including user/role management (for "Admin") - -### The Five Roles - -Stored in database `roles` table, each referencing a `permission_set_name`: - -1. **Mitglied** → "own_data" (is_system_role=true, default) -2. **Vorstand** → "read_only" -3. **Kassenwart** → "normal_user" -4. **Buchhaltung** → "read_only" -5. **Admin** → "admin" - ---- - -## MVP Scope - -### What We're Building - -**Core Authorization System:** -- ✅ Hardcoded PermissionSets module with 4 permission sets -- ✅ Role database table and CRUD interface -- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets -- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle) -- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`) -- ✅ UI authorization helpers for conditional rendering -- ✅ Special case: Member email validation for linked users -- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)` -- ✅ Seed data for 5 roles - -**Benefits of Hardcoded Approach:** -- **Speed:** 2-3 weeks vs. 4-5 weeks for DB-backed -- **Performance:** < 1 microsecond per check (pure function call) -- **Simplicity:** No cache, no DB queries, easy to reason about -- **Version Control:** All permission changes tracked in Git -- **Testing:** Deterministic, no DB setup needed - -**Clear Migration Path to Phase 3:** -- Architecture document defines exact DB schema for future -- HasPermission check can be swapped for DB-querying version -- Role->PermissionSet link remains unchanged - ---- - -## Implementation Strategy - -### Test-Driven Development - -**Every issue follows TDD:** -1. Write failing tests first -2. Implement minimum code to pass tests -3. Refactor if needed -4. All tests must pass before moving on - -**Test Types:** -- **Unit Tests:** Individual modules (PermissionSets, Policy checks, Helpers) -- **Integration Tests:** Cross-resource authorization, special cases -- **LiveView Tests:** UI rendering, page permissions -- **E2E Tests:** Complete user journeys (one per role) - -### Incremental Rollout - -**Feature Flag Approach:** -- Implement behind environment variable `ENABLE_RBAC` -- Default: `false` (existing auth remains active) -- Test thoroughly in staging -- Flip flag in production after validation -- Allows instant rollback if needed - -### Definition of Done (All Issues) - -- [ ] All acceptance criteria met -- [ ] All tests written and passing -- [ ] Code reviewed and approved -- [ ] Documentation updated -- [ ] No linter errors -- [ ] Manual testing completed -- [ ] Feature flag tested (on/off states) - ---- - -## Issue Breakdown - -### Sprint 1: Foundation (Week 1) - -#### Issue #1: Create Authorization Domain and Role Resource - -**Size:** M (2 days) -**Dependencies:** None -**Assignable to:** Backend Developer - -**Description:** - -Create the authorization domain in Ash with the `Role` resource. This establishes the foundation for all authorization logic. - -**Tasks:** - -1. Create `lib/mv/authorization/` directory -2. Create `lib/mv/authorization/role.ex` Ash resource with: - - `id` (UUIDv7, primary key) - - `name` (String, unique, required) - e.g., "Vorstand", "Admin" - - `description` (String, optional) - - `permission_set_name` (String, required) - must be one of: "own_data", "read_only", "normal_user", "admin" - - `is_system_role` (Boolean, default false) - prevents deletion - - timestamps -3. Add validation: `permission_set_name` must exist in `PermissionSets.all_permission_sets/0` -4. Add `role_id` (UUID, nullable, foreign key) to `users` table -5. Add `belongs_to :role` relationship in User resource -6. Run `mix ash.codegen` to generate migrations -7. Review and apply migrations - -**Acceptance Criteria:** - -- [ ] Role resource created with all fields -- [ ] Migration applied successfully -- [ ] User.role relationship works -- [ ] Validation prevents invalid `permission_set_name` -- [ ] `is_system_role` flag present - -**Test Strategy:** - -**Smoke Tests Only** (detailed behavior tests in later issues): - -- Role resource can be loaded via `Code.ensure_loaded?(Mv.Authorization.Role)` -- Migration created valid table (manually verify with `psql`) -- User resource can be loaded and has `:role` in `relationships()` - -**No extensive behavior tests** - those come in Issue #3 (Role CRUD). - -**Test File:** `test/mv/authorization/role_test.exs` (minimal smoke tests) - ---- - -#### Issue #2: PermissionSets Elixir Module (Hardcoded Permissions) - -**Size:** M (2 days) -**Dependencies:** None -**Can work in parallel:** Yes (parallel with #1) -**Assignable to:** Backend Developer - -**Description:** - -Create the core `PermissionSets` module that defines all four permission sets with their resource and page permissions. This is the heart of the MVP's authorization logic. - -**Tasks:** - -1. Create `lib/mv/authorization/permission_sets.ex` -2. Define module with `@moduledoc` explaining the 4 permission sets -3. Define types: - ```elixir - @type scope :: :own | :linked | :all - @type action :: :read | :create | :update | :destroy - @type resource_permission :: %{ - resource: String.t(), - action: action(), - scope: scope(), - granted: boolean() - } - @type permission_set :: %{ - resources: [resource_permission()], - pages: [String.t()] - } - ``` -4. Implement `get_permissions/1` for each of the 4 permission sets -5. Implement `all_permission_sets/0` returning `[:own_data, :read_only, :normal_user, :admin]` -6. Implement `valid_permission_set?/1` checking if name is in the list -7. Implement `permission_set_name_to_atom/1` with error handling -8. Add comprehensive `@doc` examples for each function - -**Permission Set Details:** - -**1. own_data (Mitglied):** -- Resources: - - User: read/update :own - - Member: read/update :linked - - CustomFieldValue: read/update :linked - - CustomField: read :all -- Pages: `["/", "/profile", "/members/:id"]` - -**2. read_only (Vorstand, Buchhaltung):** -- Resources: - - User: read :own, update :own - - Member: read :all - - CustomFieldValue: read :all - - CustomField: read :all -- Pages: `["/", "/members", "/members/:id", "/custom_field_values"]` - -**3. normal_user (Kassenwart):** -- Resources: - - User: read/update :own - - Member: read/create/update :all (no destroy for safety) - - CustomFieldValue: read/create/update/destroy :all - - CustomField: read :all -- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/custom_field_values", "/custom_field_values/new", "/custom_field_values/:id/edit"]` - -**4. admin:** -- Resources: - - User: read/update/destroy :all - - Member: read/create/update/destroy :all - - CustomFieldValue: read/create/update/destroy :all - - CustomField: read/create/update/destroy :all - - Role: read/create/update/destroy :all -- Pages: `["*"]` (wildcard = all pages) - -**Acceptance Criteria:** - -- [ ] Module created with all 4 permission sets -- [ ] `get_permissions/1` returns correct structure for each set -- [ ] `valid_permission_set?/1` works for atoms and strings -- [ ] `permission_set_name_to_atom/1` handles errors gracefully -- [ ] All functions have `@doc` and `@spec` -- [ ] Code is readable and well-commented - -**Test Strategy (TDD):** - -**Structure Tests:** -- `get_permissions(:own_data)` returns map with `:resources` and `:pages` keys -- Each permission set returns list of resource permissions -- Each resource permission has required keys: `:resource`, `:action`, `:scope`, `:granted` -- Pages lists are non-empty (except potentially for restricted roles) - -**Permission Content Tests:** -- `:own_data` allows User read/update with scope :own -- `:own_data` allows Member/CustomFieldValue read/update with scope :linked -- `:read_only` allows Member/CustomFieldValue read with scope :all -- `:read_only` does NOT allow Member/CustomFieldValue create/update/destroy -- `:normal_user` allows Member/CustomFieldValue full CRUD with scope :all -- `:admin` allows everything with scope :all -- `:admin` has wildcard page permission "*" - -**Validation Tests:** -- `valid_permission_set?("own_data")` returns true -- `valid_permission_set?(:admin)` returns true -- `valid_permission_set?("invalid")` returns false -- `permission_set_name_to_atom("own_data")` returns `{:ok, :own_data}` -- `permission_set_name_to_atom("invalid")` returns `{:error, :invalid_permission_set}` - -**Edge Cases:** -- All 4 sets defined in `all_permission_sets/0` -- Function doesn't crash on nil input (returns false/error tuple) - -**Test File:** `test/mv/authorization/permission_sets_test.exs` - ---- - -#### Issue #3: Role CRUD LiveViews - -**Size:** M (3 days) -**Dependencies:** #1 (Role resource) -**Assignable to:** Backend Developer + Frontend Developer - -**Description:** - -Create LiveView interface for administrators to manage roles. Only admins should be able to access this. - -**Tasks:** - -1. Create `lib/mv_web/live/role_live/` directory -2. Implement `index.ex` - List all roles -3. Implement `show.ex` - View role details -4. Implement `form.ex` - Create/Edit role form component -5. Add routes in `router.ex` under `/admin` scope -6. Create table component showing: name, description, permission_set_name, is_system_role -7. Add form validation for `permission_set_name` (dropdown with 4 options) -8. Prevent deletion of system roles (UI + backend) -9. Add flash messages for success/error -10. Style with existing DaisyUI theme - -**Acceptance Criteria:** - -- [ ] Index page lists all roles -- [ ] Show page displays role details -- [ ] Form allows creating new roles -- [ ] Form allows editing non-system roles -- [ ] `permission_set_name` is dropdown (not free text) -- [ ] Cannot delete system roles (grayed out button + backend check) -- [ ] All CRUD operations work -- [ ] Routes are under `/admin/roles` - -**Test Strategy (TDD):** - -**LiveView Mount Tests:** -- Index page mounts successfully -- Index page loads all roles from database -- Show page mounts with valid role ID -- Show page returns 404 for invalid role ID - -**CRUD Operation Tests:** -- Create new role with valid data succeeds -- Create new role with invalid `permission_set_name` shows error -- Update role name succeeds -- Update system role's `permission_set_name` succeeds -- Delete non-system role succeeds -- Delete system role fails with error message - -**UI Rendering Tests:** -- Index page shows table with role names -- System roles have badge/indicator -- Delete button disabled for system roles -- Form dropdown shows all 4 permission sets -- Flash messages appear after actions - -**Test File:** `test/mv_web/live/role_live_test.exs` - ---- - -### Sprint 2: Policies (Week 2) - -#### Issue #6: Custom Policy Check - HasPermission - -**Size:** L (3-4 days) -**Dependencies:** #2 (PermissionSets), #3 (Role resource exists) -**Assignable to:** Senior Backend Developer - -**Description:** - -Create the core custom Ash Policy Check that reads permissions from the `PermissionSets` module and applies them to Ash queries. This is the bridge between hardcoded permissions and Ash's authorization system. - -**Tasks:** - -1. Create `lib/mv/authorization/checks/has_permission.ex` -2. Implement `use Ash.Policy.Check` -3. Implement `describe/1` - returns human-readable description -4. Implement `match?/3` - the core authorization logic: - - Extract `actor.role.permission_set_name` - - Convert to atom via `PermissionSets.permission_set_name_to_atom/1` - - Call `PermissionSets.get_permissions/1` - - Find matching permission for current resource + action - - Apply scope filter -5. Implement `apply_scope/3` helper: - - `:all` → `:authorized` (no filter) - - `:own` → `{:filter, expr(id == ^actor.id)}` - - `:linked` → resource-specific logic: - - Member: `{:filter, expr(user_id == ^actor.id)}` - - CustomFieldValue: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!) -6. Handle errors gracefully: - - No actor → `{:error, :no_actor}` - - No role → `{:error, :no_role}` - - Invalid permission_set_name → `{:error, :invalid_permission_set}` - - No matching permission → `{:error, :no_permission}` -7. Add logging for authorization failures (debug level) -8. Add comprehensive `@doc` with examples - -**Acceptance Criteria:** - -- [ ] Check module implements `Ash.Policy.Check` behavior -- [ ] `match?/3` correctly evaluates permissions from PermissionSets -- [ ] Scope filters work correctly (:all, :own, :linked) -- [ ] `:linked` scope handles Member and CustomFieldValue differently -- [ ] Errors are handled gracefully (no crashes) -- [ ] Authorization failures are logged -- [ ] Module is well-documented - -**Test Strategy (TDD):** - -**Permission Lookup Tests:** -- Actor with :admin permission_set has permission for all resources/actions -- Actor with :read_only permission_set has read permission for Member -- Actor with :read_only permission_set does NOT have create permission for Member -- Actor with :own_data permission_set has update permission for User with scope :own - -**Scope Application Tests - :all:** -- Actor with scope :all can access any record -- Query returns all records in database - -**Scope Application Tests - :own:** -- Actor with scope :own can access record where record.id == actor.id -- Actor with scope :own cannot access record where record.id != actor.id -- Query filters to only actor's own record - -**Scope Application Tests - :linked:** -- Actor with scope :linked can access Member where member.user_id == actor.id -- Actor with scope :linked can access CustomFieldValue where custom_field_value.member.user_id == actor.id (relationship traversal!) -- Actor with scope :linked cannot access unlinked member -- Query correctly filters based on user_id relationship - -**Error Handling Tests:** -- `match?` with nil actor returns `{:error, :no_actor}` -- `match?` with actor missing role returns `{:error, :no_role}` -- `match?` with invalid permission_set_name returns `{:error, :invalid_permission_set}` -- `match?` with no matching permission returns `{:error, :no_permission}` -- No crashes on edge cases - -**Logging Tests:** -- Authorization failure logs at debug level -- Log includes actor ID, resource, action, reason - -**Test Files:** -- `test/mv/authorization/checks/has_permission_test.exs` - ---- - -#### Issue #7: Member Resource Policies - -**Size:** M (2 days) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #8, #9, #10) -**Assignable to:** Backend Developer - -**Description:** - -Add authorization policies to the Member resource using the new `HasPermission` check. - -**Tasks:** - -1. Open `lib/mv/membership/member.ex` -2. Add `policies` block at top of resource (before actions) -3. Configure policy to `Mv.Authorization.Checks.HasPermission` -4. Add policy for each action: - - `:read` → check HasPermission for :read - - `:create` → check HasPermission for :create - - `:update` → check HasPermission for :update - - `:destroy` → check HasPermission for :destroy -5. Add special policy: Allow user to read/update their linked member (before general policy) - ```elixir - policy action_type(:read) do - authorize_if expr(user_id == ^actor(:id)) - end - ``` -6. Ensure policies load actor with `:role` relationship preloaded -7. Test policies with different actors - -**Policy Order (Critical!):** -1. Allow user to access their own linked member (most specific) -2. Check HasPermission (general authorization) -3. Default: Forbid - -**Acceptance Criteria:** - -- [ ] Policies block added to Member resource -- [ ] All CRUD actions protected by HasPermission -- [ ] Special case: User can always access linked member -- [ ] Policy order is correct (specific before general) -- [ ] Actor preloads :role relationship -- [ ] All policies tested - -**Test Strategy (TDD):** - -**Policy Tests for :own_data (Mitglied):** -- User can read their linked member (user_id matches) -- User can update their linked member -- User cannot read unlinked member (returns empty list or forbidden) -- User cannot create member -- Verify scope :linked works - -**Policy Tests for :read_only (Vorstand):** -- User can read all members (returns all records) -- User cannot create member (returns Forbidden) -- User cannot update any member (returns Forbidden) -- User cannot destroy any member (returns Forbidden) - -**Policy Tests for :normal_user (Kassenwart):** -- User can read all members -- User can create new member -- User can update any member -- User cannot destroy member (not in permission set) - -**Policy Tests for :admin:** -- User can perform all CRUD operations on any member -- No restrictions - -**Test File:** `test/mv/membership/member_policies_test.exs` - ---- - -#### Issue #8: User Resource Policies - -**Size:** M (2 days) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #7, #9, #10) -**Assignable to:** Backend Developer -**Status:** ✅ **COMPLETED** - -**Description:** - -Add authorization policies to the User resource. Users can always read their own credentials (via bypass), and update their own credentials (via HasPermission with scope :own). - -**Implementation Pattern:** - -Following the same pattern as Member resource: -- **Bypass for READ** - Handles list queries (auto_filter) -- **HasPermission for UPDATE** - Handles updates with scope :own - -**Tasks:** - -1. ✅ Open `lib/accounts/user.ex` -2. ✅ Add `policies` block -3. ✅ Add AshAuthentication bypass (registration/login without actor) -4. ✅ ~~Add NoActor bypass (test environment only)~~ **REMOVED** - NoActor bypass was removed to prevent masking authorization bugs. All tests now use `system_actor`. -5. ✅ Add bypass for READ: Allow user to always read their own account - ```elixir - bypass action_type(:read) do - description "Users can always read their own account" - authorize_if expr(id == ^actor(:id)) - end - ``` -6. ✅ Add general policy: Check HasPermission for all actions (including UPDATE with scope :own) -7. ✅ Ensure :destroy is admin-only (via HasPermission) -8. ✅ Preload :role relationship for actor in tests - -**Policy Order:** -1. ✅ AshAuthentication bypass (registration/login) -2. ✅ Bypass: User can READ own account (id == actor.id) -3. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all) -4. ✅ Default: Ash implicitly forbids (fail-closed) - -**Note:** NoActor bypass was removed. All tests now use `system_actor` for authorization. - -**Why Bypass for READ but not UPDATE?** - -- **READ list queries**: No record at strict_check time → bypass with `expr()` needed for auto_filter ✅ -- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :own` correctly ✅ - -This ensures `scope :own` in PermissionSets is actually used (not redundant). - -**Acceptance Criteria:** - -- ✅ User can always read own credentials (via bypass) -- ✅ User can always update own credentials (via HasPermission with scope :own) -- ✅ Only admin can read/update other users (scope :all) -- ✅ Only admin can destroy users (scope :all) -- ✅ Policy order is correct (AshAuth → Bypass READ → HasPermission) -- ✅ Actor preloads :role relationship -- ✅ All tests pass (30/31 pass, 1 skipped) - -**Test Results:** - -**Test File:** `test/mv/accounts/user_policies_test.exs` -- ✅ 31 tests total: 30 passing, 1 skipped (AshAuthentication edge case) -- ✅ Tests for all 4 permission sets: own_data, read_only, normal_user, admin -- ✅ Tests for AshAuthentication bypass (registration/login) -- ✅ Tests use system_actor for authorization (NoActor bypass removed) -- ✅ Tests verify scope :own is used for UPDATE (not redundant) - ---- - -#### Issue #9: CustomFieldValue Resource Policies - -**Size:** M (2 days) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #7, #8, #10) -**Assignable to:** Backend Developer - -**Description:** - -Add authorization policies to the CustomFieldValue resource. CustomFieldValues are linked to members, which are linked to users. - -**Tasks:** - -1. Open `lib/mv/membership/custom_field_value.ex` -2. Add `policies` block -3. Add special policy: Allow user to read/update custom field values of their linked member - ```elixir - policy action_type([:read, :update]) do - authorize_if expr(member.user_id == ^actor(:id)) - end - ``` -4. Add general policy: Check HasPermission -5. Ensure CustomFieldValue preloads :member relationship for scope checks -6. Preload :role relationship for actor - -**Policy Order:** -1. Allow user to read/update properties of linked member -2. Check HasPermission -3. Default: Forbid - -**Acceptance Criteria:** - -- [ ] User can access properties of their linked member -- [ ] Policy traverses Member -> User relationship correctly -- [ ] HasPermission check works for other scopes -- [ ] Actor preloads :role relationship - -**Test Strategy (TDD):** - -**Linked CustomFieldValues Tests (:own_data):** -- User can read custom field values of their linked member -- User can update custom field values of their linked member -- User cannot read custom field values of unlinked members -- Verify relationship traversal works (custom_field_value.member.user_id) - -**Read-Only Tests:** -- User with :read_only can read all custom field values -- User with :read_only cannot create/update custom field values - -**Normal User Tests:** -- User with :normal_user can CRUD custom field values - -**Admin Tests:** -- Admin can perform all operations - -**Test File:** `test/mv/membership/custom_field_value_policies_test.exs` - ---- - -#### Issue #10: CustomField Resource Policies - -**Size:** S (1 day) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #7, #8, #9) -**Assignable to:** Backend Developer - -**Description:** - -Add authorization policies to the CustomField resource. CustomFields are admin-managed, but readable by all. - -**Tasks:** - -1. Open `lib/mv/membership/custom_field.ex` -2. Add `policies` block -3. Add read policy: All authenticated users can read (scope :all) -4. Add write policies: Only admin can create/update/destroy -5. Use HasPermission check - -**Acceptance Criteria:** - -- [ ] All users can read custom fields -- [ ] Only admin can create/update/destroy custom fields -- [ ] Policies tested - -**Test Strategy (TDD):** - -**Read Access (All Roles):** -- User with :own_data can read all custom fields -- User with :read_only can read all custom fields -- User with :normal_user can read all custom fields -- User with :admin can read all custom fields - -**Write Access (Admin Only):** -- Non-admin cannot create custom field (Forbidden) -- Non-admin cannot update custom field (Forbidden) -- Non-admin cannot destroy custom field (Forbidden) -- Admin can create custom field -- Admin can update custom field -- Admin can destroy custom field - -**Test File:** `test/mv/membership/custom_field_policies_test.exs` - ---- - -#### Issue #11: Page Permission Router Plug - -**Size:** S (1 day) -**Dependencies:** #2 (PermissionSets), #6 (HasPermission) -**Can work in parallel:** Yes (after #2 and #6) -**Assignable to:** Backend Developer - -**Description:** - -Create a Phoenix plug that checks if the current user has permission to access the requested page/route. This runs before LiveView mounts. - -**Tasks:** - -1. Create `lib/mv_web/plugs/check_page_permission.ex` -2. Implement `init/1` and `call/2` -3. Extract page path from `conn.private[:phoenix_route]` (route template like "/members/:id") -4. Get user from `conn.assigns[:current_user]` -5. Get user's role and permission_set_name -6. Call `PermissionSets.get_permissions/1` to get allowed pages list -7. Match requested path against allowed patterns: - - Exact match: "/members" == "/members" - - Dynamic match: "/members/:id" matches "/members/123" - - Wildcard: "*" matches everything (admin) -8. If unauthorized: redirect to "/" with flash error "You don't have permission to access this page." -9. If authorized: continue (conn not halted) -10. Add plug to router pipelines (`:browser`, `:require_authenticated_user`) - -**Acceptance Criteria:** - -- [ ] Plug checks page permissions from PermissionSets -- [ ] Static routes work ("/members") -- [ ] Dynamic routes work ("/members/:id" matches "/members/123") -- [ ] Wildcard works for admin ("*") -- [ ] Unauthorized users redirected with flash message -- [ ] Plug added to appropriate router pipelines - -**Test Strategy (TDD):** - -**Static Route Tests:** -- User with permission for "/members" can access (conn not halted) -- User without permission for "/members" is denied (conn halted, redirected to "/") -- Flash error message present after denial - -**Dynamic Route Tests:** -- User with "/members/:id" permission can access "/members/123" -- User with "/members/:id/edit" permission can access "/members/456/edit" -- User with only "/members/:id" cannot access "/members/123/edit" -- Pattern matching works correctly - -**Wildcard Tests:** -- Admin with "*" permission can access any page -- Wildcard overrides all other checks - -**Unauthenticated User Tests:** -- Nil current_user is redirected to login -- Login redirect preserves attempted path (optional feature) - -**Error Handling Tests:** -- User with invalid permission_set_name is denied -- User with no role is denied -- Error is logged but user sees generic message - -**Test File:** `test/mv_web/plugs/check_page_permission_test.exs` - ---- - -### Sprint 3: Special Cases & Seeds (Week 3) - -#### Issue #12: Member Email Validation for Linked Members - -**Size:** M (2 days) -**Dependencies:** #7 (Member policies), #8 (User policies) -**Assignable to:** Backend Developer - -**Description:** - -Implement special validation: Only admins can edit a member's email if that member is linked to a user. This prevents breaking email synchronization. - -**Tasks:** - -1. Open `lib/mv/membership/member.ex` -2. Add custom validation in `validations` block: - ```elixir - validate changing(:email), on: :update do - validate &validate_email_change_permission/2 - end - ``` -3. Implement `validate_email_change_permission/2`: - - Check if member has `user_id` (is linked) - - If linked: Check if actor has User.update permission with scope :all (admin) - - If not admin: Return error "Only administrators can change email for members linked to users" - - If not linked: Allow change -4. Use `PermissionSets.get_permissions/1` to check admin status -5. Add tests for all cases - -**Acceptance Criteria:** - -- [ ] Non-admin can edit email of unlinked member -- [ ] Non-admin cannot edit email of linked member -- [ ] Admin can edit email of linked member -- [ ] Validation only runs when email changes -- [ ] Error message is clear and helpful - -**Test Strategy (TDD):** - -**Unlinked Member Tests:** -- User with :normal_user can update email of unlinked member -- User with :read_only cannot update email (caught by policy, not validation) -- Validation doesn't block if member.user_id is nil - -**Linked Member Tests:** -- User with :normal_user cannot update email of linked member (validation error) -- Error message mentions "administrators" and "linked to users" -- User with :admin can update email of linked member (validation passes) - -**No-Op Tests:** -- Validation doesn't run if email didn't change -- Updating other fields (name, address) works normally - -**Test File:** `test/mv/membership/member_email_validation_test.exs` - ---- - -#### Issue #13: Seed Data - Roles and Default Assignment - -**Size:** S (1 day) -**Dependencies:** #2 (PermissionSets), #3 (Role resource) -**Can work in parallel:** Yes (parallel with #12 after #2 and #3 complete) -**Assignable to:** Backend Developer - -**Description:** - -Create seed data for 5 roles and assign default "Mitglied" role to existing users. Optionally designate one admin via environment variable. - -**Tasks:** - -1. Create `priv/repo/seeds/authorization_seeds.exs` -2. Seed 5 roles using `Ash.Seed.seed!/2` or create actions: - - **Mitglied:** name="Mitglied", description="Default member role", permission_set_name="own_data", is_system_role=true - - **Vorstand:** name="Vorstand", description="Board member with read access", permission_set_name="read_only", is_system_role=false - - **Kassenwart:** name="Kassenwart", description="Treasurer with full member management", permission_set_name="normal_user", is_system_role=false - - **Buchhaltung:** name="Buchhaltung", description="Accounting with read access", permission_set_name="read_only", is_system_role=false - - **Admin:** name="Admin", description="Administrator with full access", permission_set_name="admin", is_system_role=false -3. Make idempotent: Use upsert logic (get by name, update if exists, create if not) -4. Assign "Mitglied" role to all users without role_id: - ```elixir - mitglied_role = Ash.get!(Role, name: "Mitglied") - users_without_role = Ash.read!(User, filter: expr(is_nil(role_id))) - Enum.each(users_without_role, fn user -> - Ash.update!(user, %{role_id: mitglied_role.id}) - end) - ``` -5. (Optional) Check for `ADMIN_EMAIL` env var, assign Admin role to that user -6. Add error handling with clear error messages -7. Add `IO.puts` statements to show progress - -**Acceptance Criteria:** - -- [ ] All 5 roles created with correct permission_set_name -- [ ] "Mitglied" has is_system_role=true -- [ ] Existing users without role get "Mitglied" role -- [ ] Optional: ADMIN_EMAIL user gets Admin role -- [ ] Seeds are idempotent (can run multiple times) -- [ ] Error messages are clear -- [ ] Progress is logged to console - -**Test Strategy (TDD):** - -**Role Creation Tests:** -- After running seeds, 5 roles exist -- Each role has correct permission_set_name: - - Mitglied → "own_data" - - Vorstand → "read_only" - - Kassenwart → "normal_user" - - Buchhaltung → "read_only" - - Admin → "admin" -- "Mitglied" role has is_system_role=true -- Other roles have is_system_role=false -- All permission_set_names are valid (exist in PermissionSets.all_permission_sets/0) - -**User Assignment Tests:** -- Users without role_id are assigned "Mitglied" role -- Users who already have role_id are not changed -- Count of users with "Mitglied" role increases by number of previously unassigned users - -**Idempotency Tests:** -- Running seeds twice doesn't create duplicate roles -- Each role name appears exactly once -- Running seeds twice doesn't reassign users who already have roles - -**Optional Admin Tests:** -- If ADMIN_EMAIL set, user with that email gets Admin role -- If ADMIN_EMAIL not set, no error occurs -- If email doesn't exist, error is logged but seeds continue - -**Error Handling Tests:** -- Seeds fail gracefully if invalid permission_set_name provided -- Error message indicates which permission_set_name is invalid - -**Test File:** `test/seeds/authorization_seeds_test.exs` - ---- - -### Sprint 4: UI & Integration (Week 4) - -#### Issue #14: UI Authorization Helper Module - -**Size:** M (2-3 days) -**Dependencies:** #2 (PermissionSets), #6 (HasPermission), #13 (Seeds - for testing) -**Assignable to:** Backend Developer + Frontend Developer - -**Description:** - -Create helper functions for UI-level authorization checks. These will be used in LiveView templates to conditionally render buttons, links, and sections based on user permissions. - -**Tasks:** - -1. Create `lib/mv_web/authorization.ex` -2. Implement `can?/3` for resource-level checks: - ```elixir - def can?(user, action, resource) when is_atom(resource) - # Returns true if user has permission for action on resource - # e.g., can?(current_user, :create, Mv.Membership.Member) - ``` -3. Implement `can?/3` for record-level checks: - ```elixir - def can?(user, action, %resource{} = record) - # Returns true if user has permission for action on specific record - # Applies scope checking (own, linked, all) - # e.g., can?(current_user, :update, member) - ``` -4. Implement `can_access_page?/2`: - ```elixir - def can_access_page?(user, page_path) - # Returns true if user's permission set includes page - # e.g., can_access_page?(current_user, "/members/new") - ``` -5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission) -6. All functions handle nil user gracefully (return false) -7. Implement resource-specific scope checking (Member vs CustomFieldValue for :linked) -8. Add comprehensive `@doc` with template examples -9. Import helper in `mv_web.ex` `html_helpers` section - -**Acceptance Criteria:** - -- [ ] `can?/3` works for resource atoms -- [ ] `can?/3` works for record structs with scope checking -- [ ] `can_access_page?/2` matches page patterns correctly -- [ ] Nil user always returns false -- [ ] Invalid permission_set_name returns false (not crash) -- [ ] Helper imported in `mv_web.ex` -- [ ] Comprehensive documentation with examples - -**Test Strategy (TDD):** - -**can?/3 with Resource Atom:** -- Returns true when user has permission for resource+action -- Admin can create Member (returns true) -- Read-only cannot create Member (returns false) -- Nil user returns false - -**can?/3 with Record Struct - Scope :all:** -- Admin can update any member (returns true for any record) -- Normal user can update any member (scope :all) - -**can?/3 with Record Struct - Scope :own:** -- User can update own User record (record.id == user.id) -- User cannot update other User record (record.id != user.id) - -**can?/3 with Record Struct - Scope :linked:** -- User can update linked Member (member.user_id == user.id) -- User cannot update unlinked Member -- User can update CustomFieldValue of linked Member (custom_field_value.member.user_id == user.id) -- User cannot update CustomFieldValue of unlinked Member -- Scope checking is resource-specific (Member vs CustomFieldValue) - -**can_access_page?/2:** -- User with page in list can access (returns true) -- User without page in list cannot access (returns false) -- Dynamic routes match correctly ("/members/:id" matches "/members/123") -- Admin wildcard "*" matches any page -- Nil user returns false - -**Error Handling:** -- User without role returns false -- User with invalid permission_set_name returns false (no crash) -- Handles missing fields gracefully - -**Test File:** `test/mv_web/authorization_test.exs` - ---- - -#### Issue #15: Admin UI for Role Management - -**Size:** M (2 days) -**Dependencies:** #14 (UI Authorization Helper) -**Assignable to:** Frontend Developer - -**Description:** - -Update Role management LiveViews to use authorization helpers for conditional rendering. Add UI polish. - -**Tasks:** - -1. Open `lib/mv_web/live/role_live/index.ex` -2. Add authorization checks for "New Role" button: - ```heex - <%= if can?(@current_user, :create, Mv.Authorization.Role) do %> - <.link patch={~p"/admin/roles/new"}>New Role - <% end %> - ``` -3. Add authorization checks for "Edit" and "Delete" buttons in table -4. Gray out/hide "Delete" for system roles -5. Update `show.ex` to hide edit button if user can't update -6. Add role badge/pill for system roles -7. Add permission_set_name badge with color coding: - - own_data → gray - - read_only → blue - - normal_user → green - - admin → red -8. Test UI with different user roles - -**Acceptance Criteria:** - -- [ ] Only admin sees "New Role" button -- [ ] Only admin sees "Edit" and "Delete" buttons -- [ ] System roles have visual indicator -- [ ] Delete button hidden/disabled for system roles -- [ ] Permission set badges are color-coded -- [ ] UI tested with all role types - -**Test Strategy (TDD):** - -**Admin View:** -- Admin sees "New Role" button -- Admin sees "Edit" buttons for all roles -- Admin sees "Delete" buttons for non-system roles -- Admin does not see "Delete" button for system roles - -**Non-Admin View:** -- Non-admin does not see "New Role" button (redirected by page permission plug anyway) -- Non-admin cannot access /admin/roles (caught by plug) - -**Visual Tests:** -- System roles have badge -- Permission set names are color-coded -- UI renders correctly - -**Test File:** `test/mv_web/live/role_live_authorization_test.exs` - ---- - -#### Issue #16: Apply UI Authorization to Existing LiveViews - -**Size:** L (3 days) -**Dependencies:** #14 (UI Authorization Helper) -**Can work in parallel:** Yes (parallel with #15) -**Assignable to:** Frontend Developer - -**Description:** - -Update all existing LiveViews (Member, User, CustomFieldValue, CustomField) to use authorization helpers for conditional rendering. - -**Tasks:** - -1. **Member LiveViews:** - - Index: Hide "New Member" if can't create - - Index: Hide "Edit" and "Delete" buttons per record if can't update/destroy - - Show: Hide "Edit" button if can't update record - - Form: Should not be accessible (caught by page permission plug) - -2. **User LiveViews:** - - Index: Only show if user is admin - - Show: Only show other users if admin, always show own profile - - Edit: Only allow editing own profile or admin editing anyone - -3. **CustomFieldValue LiveViews:** - - Similar to Member (hide create/edit/delete based on permissions) - -4. **CustomField LiveViews:** - - All users can view - - Only admin can create/edit/delete - -5. **Navbar:** - - Only show "Admin" dropdown if user has admin permission set - - Only show "Roles" link if can access /admin/roles - - Only show "Members" link if can access /members - - Always show "Profile" link - -6. Test all views with all 5 role types - -**Acceptance Criteria:** - -- [ ] All LiveViews use `can?/3` for conditional rendering -- [ ] Buttons/links hidden when user lacks permission -- [ ] Navbar shows appropriate links per role -- [ ] Tested with all 5 roles (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) -- [ ] UI is clean (no awkward empty spaces from hidden buttons) - -**Test Strategy (TDD):** - -**Member Index - Mitglied (own_data):** -- Does not see "New Member" button -- Does not see list of members (empty or filtered) -- Can only see own linked member if navigated directly - -**Member Index - Vorstand (read_only):** -- Sees full member list -- Does not see "New Member" button -- Does not see "Edit" or "Delete" buttons - -**Member Index - Kassenwart (normal_user):** -- Sees full member list -- Sees "New Member" button -- Sees "Edit" button for all members -- Does not see "Delete" button (not in permission set) - -**Member Index - Admin:** -- Sees everything (New, Edit, Delete) - -**Navbar Tests (all roles):** -- Mitglied: Sees only "Home" and "Profile" -- Vorstand: Sees "Home", "Members" (read-only), "Profile" -- Kassenwart: Sees "Home", "Members", "Properties", "Profile" -- Buchhaltung: Sees "Home", "Members" (read-only), "Profile" -- Admin: Sees "Home", "Members", "Custom Field Values", "Custom Fields", "Admin", "Profile" - -**Test Files:** -- `test/mv_web/live/member_live_authorization_test.exs` -- `test/mv_web/live/user_live_authorization_test.exs` -- `test/mv_web/live/custom_field_value_live_authorization_test.exs` -- `test/mv_web/live/custom_field_live_authorization_test.exs` -- `test/mv_web/components/navbar_authorization_test.exs` - ---- - -#### Issue #17: Integration Tests - Complete User Journeys - -**Size:** L (3 days) -**Dependencies:** All above (full system must be functional) -**Assignable to:** Backend Developer - -**Description:** - -Write comprehensive integration tests that follow complete user journeys for each role. These tests verify that policies, UI helpers, and page permissions all work together correctly. - -**Tasks:** - -1. Create test file for each role: - - `test/integration/mitglied_journey_test.exs` - - `test/integration/vorstand_journey_test.exs` - - `test/integration/kassenwart_journey_test.exs` - - `test/integration/buchhaltung_journey_test.exs` - - `test/integration/admin_journey_test.exs` - -2. Each test follows a complete user flow: - - Login as user with role - - Navigate to allowed pages - - Attempt to access forbidden pages - - Perform allowed actions - - Attempt forbidden actions - - Verify UI shows/hides appropriate elements - -3. Test cross-cutting concerns: - - Email synchronization (Member <-> User) - - User-Member linking (admin only) - - System role protection - -**Acceptance Criteria:** - -- [ ] One integration test per role (5 total) -- [ ] Tests cover complete user journeys -- [ ] Tests verify both backend (policies) and frontend (UI helpers) -- [ ] Tests verify page permissions -- [ ] Tests verify special cases (email, linking, system roles) -- [ ] All tests pass - -**Test Strategy:** - -**Mitglied Journey:** -1. Login as Mitglied user -2. Can access home page and profile -3. Cannot access /members (redirected) -4. Cannot access /admin/roles (redirected) -5. Can view own linked member via direct URL -6. Can update own member data -7. Cannot update unlinked member -8. Can update own user credentials -9. Cannot view other users - -**Vorstand Journey:** -1. Login as Vorstand user -2. Can access /members (reads all members) -3. Cannot create member (no button in UI, backend forbids) -4. Cannot edit member (no button in UI, backend forbids) -5. Can access /members/:id (read-only view) -6. Cannot access /members/:id/edit (page permission denies) -7. Can update own credentials -8. Cannot access /admin/roles - -**Kassenwart Journey:** -1. Login as Kassenwart user -2. Can access /members -3. Can create new member -4. Can edit any member (except email if linked - see special case) -5. Cannot delete member -6. Can manage properties -7. Cannot manage custom fields (read-only) -8. Cannot access /admin/roles - -**Buchhaltung Journey:** -1. Login as Buchhaltung user -2. Can access /members (read-only) -3. Cannot create/edit members -4. Can view properties (read-only) -5. Same restrictions as Vorstand - -**Admin Journey:** -1. Login as Admin user -2. Can access all pages (wildcard permission) -3. Can CRUD all resources -4. Can edit member email even if linked -5. Can manage roles -6. Cannot delete system roles (backend prevents) -7. Can link/unlink users and members -8. Can edit any user's credentials - -**Special Cases Tests:** -- Member email editing (admin vs non-admin for linked member) -- System role deletion (always fails) -- User without role (access denied everywhere) -- User with invalid permission_set_name (access denied) - -**Test Files:** -- `test/integration/mitglied_journey_test.exs` -- `test/integration/vorstand_journey_test.exs` -- `test/integration/kassenwart_journey_test.exs` -- `test/integration/buchhaltung_journey_test.exs` -- `test/integration/admin_journey_test.exs` -- `test/integration/special_cases_test.exs` - ---- - -## Dependencies & Parallelization - -### Dependency Graph - -``` - ┌──────────────────┐ - │ Issue #1 │ - │ Auth Domain │ - │ + Role Res │ - └────────┬─────────┘ - │ - ┌────────────┴────────────┐ - │ │ - ┌───────▼────────┐ ┌───────▼────────┐ - │ Issue #2 │ │ Issue #3 │ - │ PermissionSets│ │ Role CRUD │ - │ Module │ │ LiveViews │ - └───────┬────────┘ └────────────────┘ - │ - │ - └────────────┬────────────┘ - │ - ┌────────▼─────────┐ - │ Issue #6 │ - │ HasPermission │ - │ Policy Check │ - └────────┬─────────┘ - │ - ┌────────────────────┼─────────────────────┐ - │ │ │ - ┌────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ - │ Issue #7 │ │ Issue #8 │ │ Issue #11 │ - │ Member │ │ User │ │ Page Plug │ - │ Policies │ │ Policies │ └──────┬──────┘ - └────┬─────┘ └──────┬──────┘ │ - │ │ │ - ┌────▼─────┐ ┌──────▼──────┐ │ - │ Issue #9 │ │ Issue #10 │ │ - │ CustomFieldValue │ │ CustomField │ │ - │ Policies │ │ Policies │ │ - └────┬─────┘ └──────┬──────┘ │ - │ │ │ - └────────────────────┴─────────────────────┘ - │ - ┌────────────┴────────────┐ - │ │ - ┌───────▼────────┐ ┌───────▼────────┐ - │ Issue #12 │ │ Issue #13 │ - │ Email Valid │ │ Seeds │ - └───────┬────────┘ └───────┬────────┘ - │ │ - └────────────┬────────────┘ - │ - ┌────────▼─────────┐ - │ Issue #14 │ - │ UI Helper │ - └────────┬─────────┘ - │ - ┌────────────┴────────────┐ - │ │ - ┌───────▼────────┐ ┌───────▼────────┐ - │ Issue #15 │ │ Issue #16 │ - │ Admin UI │ │ Apply UI Auth│ - └───────┬────────┘ └───────┬────────┘ - │ │ - └────────────┬────────────┘ - │ - ┌────────▼─────────┐ - │ Issue #17 │ - │ Integration │ - │ Tests │ - └──────────────────┘ -``` - -### Parallelization Opportunities - -**After Issue #1:** -- Issues #2 and #3 can run in parallel - -**After Issue #6:** -- Issues #7, #8, #9, #10, #11 can ALL run in parallel (5 issues!) -- This is the main parallelization opportunity - -**After Issues #7-#11:** -- Issues #12 and #13 can run in parallel - -**After Issue #14:** -- Issues #15 and #16 can run in parallel - -### Sprint Breakdown - -| Sprint | Issues | Duration | Can Parallelize | -|--------|--------|----------|-----------------| -| Sprint 1 | #1, #2, #3 | Week 1 | #2 and #3 after #1 | -| Sprint 2 | #6, #7, #8, #9, #10, #11 | Week 2 | #7-#11 after #6 (5 parallel!) | -| Sprint 3 | #12, #13 | Week 3 | Yes (2 parallel) | -| Sprint 4 | #14, #15, #16, #17 | Week 4 | #15 & #16 after #14 | - ---- - -## Testing Strategy - -### Test-Driven Development Process - -**For Every Issue:** -1. Read acceptance criteria -2. Write failing tests covering all criteria -3. Verify tests fail (red) -4. Implement minimum code to pass -5. Verify tests pass (green) -6. Refactor if needed -7. All tests still pass - -### Test Coverage Goals - -**Total Estimated Tests: 180+** - -| Test Type | Count | Coverage | -|-----------|-------|----------| -| Unit Tests | ~80 | PermissionSets module, Policy checks, Scope logic, UI helpers | -| Integration Tests | ~70 | Cross-resource authorization, Special cases, Email validation | -| LiveView Tests | ~25 | UI rendering, Page permissions, Conditional elements | -| E2E Journey Tests | ~5 | Complete user flows (one per role) | - -### What to Test (Focus on Behavior) - -**DO Test:** -- Permission lookups return correct results -- Policies allow/deny actions correctly -- Scope filters work (own, linked, all) -- UI elements show/hide based on permissions -- Page access is controlled -- Special cases work (email, system roles) -- Error handling (no crashes) - -**DON'T Test:** -- Database schema existence -- Table columns (Ash generates these) -- Implementation details -- Private functions (test through public API) - -### Test Files Structure - -``` -test/ -├── mv/ -│ └── authorization/ -│ ├── permission_sets_test.exs # Issue #2 -│ ├── role_test.exs # Issue #1 (smoke) -│ └── checks/ -│ └── has_permission_test.exs # Issue #6 -├── mv/accounts/ -│ └── user_policies_test.exs # Issue #8 -├── mv/membership/ -│ ├── member_policies_test.exs # Issue #7 -│ ├── member_email_validation_test.exs # Issue #12 -│ ├── custom_field_value_policies_test.exs # Issue #9 -│ └── custom_field_policies_test.exs # Issue #10 -├── mv_web/ -│ ├── authorization_test.exs # Issue #14 -│ ├── plugs/ -│ │ └── check_page_permission_test.exs # Issue #11 -│ └── live/ -│ ├── role_live_test.exs # Issue #3 -│ ├── role_live_authorization_test.exs # Issue #15 -│ ├── member_live_authorization_test.exs # Issue #16 -│ ├── user_live_authorization_test.exs # Issue #16 -│ ├── custom_field_value_live_authorization_test.exs # Issue #16 -│ └── custom_field_live_authorization_test.exs # Issue #16 -├── integration/ -│ ├── mitglied_journey_test.exs # Issue #17 -│ ├── vorstand_journey_test.exs # Issue #17 -│ ├── kassenwart_journey_test.exs # Issue #17 -│ ├── buchhaltung_journey_test.exs # Issue #17 -│ ├── admin_journey_test.exs # Issue #17 -│ └── special_cases_test.exs # Issue #17 -└── seeds/ - └── authorization_seeds_test.exs # Issue #13 -``` - ---- - -## Migration & Rollback - -### Database Migrations - -**Issue #1 creates one migration:** - -```elixir -# priv/repo/migrations/TIMESTAMP_add_authorization.exs -defmodule Mv.Repo.Migrations.AddAuthorization do - use Ecto.Migration - - def up do - # Create roles table - create table(:roles, primary_key: false) do - add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") - add :name, :string, null: false - add :description, :text - add :permission_set_name, :string, null: false - add :is_system_role, :boolean, default: false, null: false - - timestamps() - end - - create unique_index(:roles, [:name]) - create index(:roles, [:permission_set_name]) - - # Add role_id to users table - alter table(:users) do - add :role_id, references(:roles, type: :binary_id, on_delete: :restrict) - end - - create index(:users, [:role_id]) - end - - def down do - drop index(:users, [:role_id]) - - alter table(:users) do - remove :role_id - end - - drop table(:roles) - end -end -``` - -### Data Migration (Seeds) - -**After migration applied:** - -Run seeds to create roles and assign defaults: - -```bash -mix run priv/repo/seeds/authorization_seeds.exs -``` - -### Rollback Plan - -**If issues discovered in production:** - -1. **Immediate Rollback:** - - Set `ENABLE_RBAC=false` environment variable - - Restart application - - Old authorization system takes over instantly - -2. **Database Rollback (if needed):** - ```bash - mix ecto.rollback --step 1 - ``` - - Removes `role_id` from users - - Removes `roles` table - - Existing auth untouched - -3. **Code Rollback:** - - Revert Git commit - - Redeploy previous version - -**Rollback Safety:** -- No existing tables modified (only additions) -- Feature flag allows instant disable -- Old auth code remains in place until RBAC proven stable - ---- - -## Risk Management - -### Identified Risks - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| **Policy order issues** | Medium | High | Clear documentation, strict order enforcement, integration tests verify policies work together | -| **Scope filter errors** | Medium | High | TDD approach, extensive scope tests (own/linked/all), test with all resource types | -| **UI/Policy divergence** | Low | Medium | UI helpers use same PermissionSets module as policies, shared logic, integration tests verify consistency | -| **Breaking existing auth** | Low | High | Feature flag allows instant rollback, parallel systems until proven, gradual rollout | -| **User without role edge case** | Low | Medium | Default "Mitglied" role assigned in seeds, validation on User.create, tests cover nil role | -| **Invalid permission_set_name** | Low | Low | Validation on Role resource, tests cover invalid names, error handling throughout | -| **Performance (not a concern)** | Very Low | Low | Hardcoded permissions are < 1 microsecond, no DB queries, no cache needed | - -### Edge Cases Handled - -**User without role:** -- Default: Access denied (no permissions) -- Seeds assign "Mitglied" to all existing users -- New users must be assigned role on creation - -**Invalid permission_set_name:** -- Role validation prevents creation -- Runtime checks handle gracefully (return false/error, no crash) -- Error logged for debugging - -**System role protection:** -- Cannot delete role with `is_system_role=true` -- UI hides delete button -- Backend validation prevents deletion -- "Mitglied" is system role by default - -**Linked member email:** -- Custom validation on Member resource -- Only admins can edit if member.user_id present -- Prevents breaking email synchronization - -**Missing actor context:** -- All policies check for actor presence -- Missing actor = access denied -- No crashes, graceful error handling - -### Performance Considerations - -**No concerns for MVP:** -- Hardcoded permissions are pure function calls -- No database queries for permission checks -- Pattern matching on small lists (< 50 items total) -- Typical check: < 1 microsecond -- Can handle 10,000+ requests/second easily - -**Future considerations (Phase 3):** -- If migrating to database-backed: add ETS cache -- Cache invalidation on role/permission changes -- Database indexes on permission tables - ---- - -## Success Criteria - -**MVP is successful when:** - -- [ ] All 15 issues completed -- [ ] All 180+ tests passing -- [ ] Zero linter errors -- [ ] Manual testing completed for all 5 roles -- [ ] Integration tests verify complete user journeys -- [ ] Feature flag tested (on/off states) -- [ ] Documentation complete -- [ ] Code review approved -- [ ] Deployed to staging and verified -- [ ] Performance verified (< 100ms per page load) -- [ ] No authorization bypasses found in security review - -**Ready for Production when:** - -- [ ] 1 week in staging with no critical issues -- [ ] All stakeholders have tested their role types -- [ ] Rollback plan tested -- [ ] Monitoring/alerting configured -- [ ] Runbook created for common issues - ---- - -## Next Steps After MVP - -**Phase 2: Field-Level Permissions (Future - 2-3 weeks)** - -- Extend PermissionSets with `:fields` key -- Implement Ash Calculations to filter readable fields -- Implement Custom Validations for writable fields -- No database changes needed -- See [Architecture Document](./roles-and-permissions-architecture.md) for details - -**Phase 3: Database-Backed Permissions (Future - 3-4 weeks)** - -- Create `permission_sets`, `permission_set_resources`, `permission_set_pages` tables -- Replace hardcoded PermissionSets module with DB queries -- Implement ETS cache for performance -- Allow runtime permission configuration -- See [Architecture Document](./roles-and-permissions-architecture.md) for migration strategy - ---- - -## Document History - -| Version | Date | Author | Changes | -|---------|------|--------|---------| -| 1.0 | 2025-01-12 | AI Assistant | Initial version with DB-backed permissions | -| 2.0 | 2025-01-13 | AI Assistant | Complete rewrite for hardcoded MVP, removed all V1 references, fixed Buchhaltung inconsistency | - ---- - -## Appendix - -### Glossary - -- **Permission Set:** A named collection of resource and page permissions (e.g., "admin", "read_only") -- **Role:** A database entity that links users to a permission set -- **Scope:** The range of records a permission applies to (:own, :linked, :all) -- **Actor:** The currently authenticated user in Ash authorization context -- **System Role:** A role that cannot be deleted (is_system_role=true) - -### Key Files - -- `lib/mv/authorization/permission_sets.ex` - Core permissions logic -- `lib/mv/authorization/checks/has_permission.ex` - Ash policy check -- `lib/mv_web/authorization.ex` - UI helper functions -- `lib/mv_web/plugs/check_page_permission.ex` - Page access control -- `priv/repo/seeds/authorization_seeds.exs` - Role seed data - -### Useful Commands - -```bash -# Run all authorization tests -mix test test/mv/authorization - -# Run integration tests only -mix test test/integration - -# Run with coverage -mix test --cover - -# Generate migrations after Ash resource changes -mix ash.codegen - -# Run seeds -mix run priv/repo/seeds/authorization_seeds.exs - -# Check for linter errors -mix credo --strict -``` - ---- - -**End of Implementation Plan** +## Scope, migration & rollback +For the MVP scope boundary, the DB migration (create `roles`, add `users.role_id`), +the seed step, and the two-tier rollback plan (`mix ecto.rollback` → code revert), see +[Architecture › Migration Strategy](./roles-and-permissions-architecture.md#migration-strategy). diff --git a/docs/roles-and-permissions-overview.md b/docs/roles-and-permissions-overview.md index 13bf7cf..6331682 100644 --- a/docs/roles-and-permissions-overview.md +++ b/docs/roles-and-permissions-overview.md @@ -63,20 +63,7 @@ During the design phase, we evaluated multiple implementation approaches to find ### Approach 1: JSONB in Roles Table -Store all permissions as a single JSONB column directly in the roles table. - -**Advantages:** -- Simplest database schema (single table) -- Very flexible structure -- No additional tables needed -- Fast to implement - -**Disadvantages:** -- Poor queryability (can't efficiently filter by specific permissions) -- No referential integrity -- Difficult to validate structure -- Hard to audit permission changes -- Can't leverage database indexes effectively +Store all permissions as a single JSONB column directly in the roles table. Simplest schema (single table), flexible, fast to implement — but poor queryability (can't filter by specific permissions), no referential integrity, hard to validate/audit, can't use indexes. **Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic. @@ -84,22 +71,7 @@ Store all permissions as a single JSONB column directly in the roles table. ### Approach 2: Normalized Database Tables -Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization. - -**Advantages:** -- Fully queryable with SQL -- Runtime configurable permissions -- Strong referential integrity -- Easy to audit changes -- Can index for performance - -**Disadvantages:** -- Complex database schema (4+ tables) -- DB queries required for every permission check -- Requires ETS cache for performance -- Needs admin UI for permission management -- Longer implementation time (4-5 weeks) -- Overkill for fixed set of 4 permission sets +Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization. Fully queryable, runtime-configurable, strong referential integrity, auditable, indexable — but complex schema (4+ tables), a DB query per check, needs ETS cache + admin UI, 4-5 weeks, overkill for 4 fixed sets. **Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP. @@ -107,20 +79,7 @@ Separate tables for `permission_sets`, `permission_set_resources`, `permission_s ### Approach 3: Custom Authorizer -Implement a custom Ash Authorizer from scratch instead of using Ash Policies. - -**Advantages:** -- Complete control over authorization logic -- Can implement any custom behavior -- Not constrained by Ash Policy DSL - -**Disadvantages:** -- Significantly more code to write and maintain -- Loses benefits of Ash's declarative policies -- Harder to test than built-in policy system -- Mixes declarative and imperative approaches -- Must reimplement filter generation for queries -- Higher bug risk +Implement a custom Ash Authorizer from scratch instead of using Ash Policies. Full control over logic — but significantly more code, loses Ash's declarative policies (must reimplement query filter generation), harder to test, mixes declarative/imperative, higher bug risk. **Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits. @@ -128,21 +87,7 @@ Implement a custom Ash Authorizer from scratch instead of using Ash Policies. ### Approach 4: Simple Role Enum -Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy. - -**Advantages:** -- Very simple to implement (< 1 week) -- No extra tables needed -- Fast performance -- Easy to understand - -**Disadvantages:** -- No separation between roles and permissions -- Can't add new roles without code changes -- No dynamic permission configuration -- Not extensible to field-level permissions -- Violates separation of concerns (role = job function, not permission set) -- Difficult to maintain as requirements grow +Add a `:role` enum field directly on User with hardcoded checks in each policy. Very simple (< 1 week), no extra tables, fast — but no separation of role (job function) from permission set, can't add roles without code changes, no dynamic config, not extensible to field-level, hard to maintain as requirements grow. **Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation. @@ -150,33 +95,11 @@ Add a simple `:role` enum field directly on User resource with hardcoded checks ### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP) -Permission Sets hardcoded in Elixir module, only Roles table in database. +Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (2-3 weeks vs 4-5), maximum performance (zero DB queries, < 1μs), pure-function testing, Git-reviewable permissions, no data migration, keeps role/permission-set separation, clear Phase 3 upgrade path. Trade-offs: permissions not editable at runtime (only role assignment), new permissions need a code deploy, unsuitable if permissions change > 1x/week, limited to the 4 predefined sets. -**Advantages:** -- Fast implementation (2-3 weeks vs 4-5 weeks) -- Maximum performance (zero DB queries, < 1 microsecond) -- Simple to test (pure functions) -- Code-reviewable permissions (visible in Git) -- No migration needed for existing data -- Clearly defined 4 permission sets as required -- Clear migration path to database-backed solution (Phase 3) -- Maintains separation of roles and permission sets +**Why Selected:** MVP requires 4 fixed sets (not custom ones), no stated need for runtime permission editing, performance is critical, fast time-to-market, and a clear upgrade path exists when runtime config becomes necessary. -**Disadvantages:** -- Permissions not editable at runtime (only role assignment possible) -- New permissions require code deployment -- Not suitable if permissions change frequently (> 1x/week) -- Limited to the 4 predefined permission sets - -**Why Selected:** -- MVP requirement is for 4 fixed permission sets (not custom ones) -- No stated requirement for runtime permission editing -- Performance is critical for authorization checks -- Fast time-to-market (2-3 weeks) -- Clear upgrade path when runtime configuration becomes necessary - -**Migration Path:** -When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module. +**Migration Path:** When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module. --- @@ -201,7 +124,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro **Resource Level (MVP):** - Controls create, read, update, destroy actions on resources -- Resources: Member, User, CustomFieldValue, CustomField, Role +- Resources: Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle, JoinRequest **Page Level (MVP):** - Controls access to LiveView pages @@ -214,7 +137,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro ### Special Cases 1. **Own Credentials:** Users can always edit their own email and password -2. **Linked Member Email:** Only admins can edit email of members linked to users +2. **Linked Member Email:** Only administrators or the linked user themselves can change the email of a member linked to a user 3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation) --- @@ -331,46 +254,39 @@ Users need to create member profiles for themselves (self-service), but only adm - Unlink members from users - Create members pre-linked to arbitrary users -### Selected Approach: Separate Ash Actions +### Selected Approach: Admin-Only `:user` Argument -Instead of complex field-level validation, we use action-based authorization. +Linking is **not** modelled as separate per-operation actions. The Member resource has a single +`create_member` and a single `update_member` action; linking and unlinking happen through an +optional **`:user` argument** on those actions. `user_id` is deliberately not accepted, so the +foreign key cannot be set directly. -### Actions on Member Resource +### How Linking Works on the Member Resource -**1. create_member_for_self** (All authenticated users) -- Automatically sets user_id = actor.id -- User cannot specify different user_id -- UI: "Create My Profile" button +**`create_member` / `update_member`** (the only Member write actions) +- The optional `:user` argument drives the relationship via `manage_relationship`. +- On update, `on_missing: :ignore` means omitting `:user` leaves the link unchanged + (no "unlink by omission"); unlink is explicit (`user: nil`). +- The policy check `ForbidMemberUserLinkUnlessAdmin` forbids the action for non-admins whenever the + `:user` argument is present (any value), so only admins may set or change the link. +- Non-admins can still create/update members as long as they do not pass `:user`. -**2. create_member** (Admin only) -- Can set user_id to any user or leave unlinked -- Full flexibility for admin -- UI: Admin member management form +**Self-service** ("a user creates a member linked to themselves") is handled on the **User** side: +the admin-only `update_user` action takes a `:member` argument for link/unlink, and the UI exposes +the linking controls only to admins. -**3. link_member_to_user** (Admin only) -- Updates existing member to set user_id -- Connects unlinked member to user account +### Why This Design? -**4. unlink_member_from_user** (Admin only) -- Sets user_id to nil -- Disconnects member from user account +**Single write path:** one create and one update action to reason about, instead of a fan-out of +`link_*`/`unlink_*` actions. -**5. update** (Permission-based) -- Normal updates (name, address, etc.) -- user_id NOT in accept list (prevents manipulation) -- Available to users with Member.update permission +**Centralized rule:** the admin-only constraint lives in one reusable policy check +(`ForbidMemberUserLinkUnlessAdmin`). -### Why Separate Actions? +**Server-Side Security:** `user_id` is never accepted directly, so it cannot be mass-assigned — +only argument-driven relationship management can change it. -**Explicit Semantics:** Each action has clear, single purpose - -**Server-Side Security:** user_id set by server, not client input - -**Better UX:** Different UI flows for different use cases - -**Simple Policies:** Authorization at action level, not field level - -**Easy Testing:** Each action independently testable +**Better UX:** distinct UI flows for self-service vs. admin linking. --- @@ -486,23 +402,7 @@ Use Custom Validations **[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples -**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach +**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Historical record of how the MVP was built (PR #346/#345) **[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards ---- - -## Summary - -The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing: -- **Speed:** 2-3 weeks implementation vs 4-5 weeks -- **Performance:** Zero database queries for authorization -- **Clarity:** Permissions in Git, reviewable and testable -- **Flexibility:** Clear migration path to database-backed system - -**User-Member linking** uses **separate Ash Actions** for clarity and security. - -**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation. - -The approach balances pragmatism for MVP delivery with extensibility for future requirements. - diff --git a/docs/user-resource-policies-implementation-summary.md b/docs/user-resource-policies-implementation-summary.md deleted file mode 100644 index c939c6b..0000000 --- a/docs/user-resource-policies-implementation-summary.md +++ /dev/null @@ -1,269 +0,0 @@ -# User Resource Authorization Policies - Implementation Summary - -**Date:** 2026-01-22 -**Status:** ✅ COMPLETED - ---- - -## Overview - -Successfully implemented authorization policies for the User resource following the Bypass + HasPermission pattern, ensuring consistency with Member resource policies and proper use of the scope concept from PermissionSets. - ---- - -## What Was Implemented - -### 1. Policy Structure in `lib/accounts/user.ex` - -```elixir -policies do - # 1. AshAuthentication Bypass - bypass AshAuthentication.Checks.AshAuthenticationInteraction do - authorize_if always() - end - - # 2. Bypass for READ (list queries via auto_filter) - bypass action_type(:read) do - description "Users can always read their own account" - authorize_if expr(id == ^actor(:id)) - end - - # 3. HasPermission for all operations (uses scope from PermissionSets) - policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role and permission set" - authorize_if Mv.Authorization.Checks.HasPermission - end -end -``` - -### 2. Test Suite in `test/mv/accounts/user_policies_test.exs` - -**Coverage:** -- ✅ 31 tests total: 30 passing, 1 skipped -- ✅ All 4 permission sets tested: `own_data`, `read_only`, `normal_user`, `admin` -- ✅ READ operations (list and single record) -- ✅ UPDATE operations (own and other users) -- ✅ CREATE operations (admin only) -- ✅ DESTROY operations (admin only) -- ✅ AshAuthentication bypass (registration/login) -- ✅ Tests use system_actor for authorization - ---- - -## Key Design Decisions - -### Decision 1: Bypass for READ, HasPermission for UPDATE - -**Rationale:** -- READ list queries have no record at `strict_check` time -- `HasPermission` returns `{:ok, false}` for queries without record -- Ash doesn't call `auto_filter` when `strict_check` returns `false` -- `expr()` in bypass is handled natively by Ash for `auto_filter` - -**Result:** -- Bypass handles READ list queries ✅ -- HasPermission handles UPDATE with `scope :own` ✅ -- No redundancy - both are necessary ✅ - -### Decision 2: No Explicit `forbid_if always()` - -**Rationale:** -- Ash implicitly forbids if no policy authorizes (fail-closed by default) -- Explicit `forbid_if always()` at the end breaks tests -- It would forbid valid operations that should be authorized by previous policies - -**Result:** -- Policies rely on Ash's implicit forbid ✅ -- Tests pass with this approach ✅ - -### Decision 3: Consistency with Member Resource - -**Rationale:** -- Member resource uses same pattern: Bypass for READ, HasPermission for UPDATE -- Consistent patterns improve maintainability and predictability -- Developers can understand authorization logic across resources - -**Result:** -- User and Member follow identical pattern ✅ -- Authorization logic is consistent throughout the app ✅ - ---- - -## The Scope Concept Is NOT Redundant - -### Initial Concern - -> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?" - -### Resolution - -**NO! The scope concept is essential:** - -1. **Documentation** - `scope :own` clearly expresses intent in PermissionSets -2. **UPDATE operations** - `scope :own` is USED by HasPermission when changeset contains record -3. **Admin operations** - `scope :all` allows admins full access -4. **Maintainability** - All permissions centralized in one place - -**Test Proof:** - -```elixir -test "can update own email", %{user: user} do - # This works via HasPermission with scope :own (NOT bypass) - {:ok, updated_user} = - user - |> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"}) - |> Ash.update(actor: user) - - assert updated_user.email # ✅ Proves scope :own is used -end -``` - ---- - -## Documentation Updates - -### 1. Created `docs/policy-bypass-vs-haspermission.md` - -Comprehensive documentation explaining: -- Why bypass is needed for READ -- Why HasPermission works for UPDATE -- Technical deep dive into Ash policy evaluation -- Test coverage proving the pattern -- Lessons learned - -### 2. Updated `docs/roles-and-permissions-architecture.md` - -- Added "Bypass vs. HasPermission: When to Use Which?" section -- Updated User Resource Policies section with correct implementation -- Updated Member Resource Policies section for consistency -- Added pattern comparison table - -### 3. Updated `docs/roles-and-permissions-implementation-plan.md` - -- Marked Issue #8 as COMPLETED ✅ -- Added implementation details -- Documented why bypass is needed -- Added test results - ---- - -## Test Results - -### All Relevant Tests Pass - -```bash -mix test test/mv/accounts/user_policies_test.exs \ - test/mv/authorization/checks/has_permission_test.exs \ - test/mv/membership/member_policies_test.exs - -# Results: -# 75 tests: 74 passing, 1 skipped -# ✅ User policies: 30/31 (1 skipped) -# ✅ HasPermission check: 21/21 -# ✅ Member policies: 23/23 -``` - -### Specific Test Coverage - -**Own Data Access (All Roles):** -- ✅ Can read own user record (via bypass) -- ✅ Can update own email (via HasPermission with scope :own) -- ✅ Cannot read other users (filtered by bypass) -- ✅ Cannot update other users (forbidden by HasPermission) -- ✅ List returns only own user (auto_filter via bypass) - -**Admin Access:** -- ✅ Can read all users (HasPermission with scope :all) -- ✅ Can update other users (HasPermission with scope :all) -- ✅ Can create users (HasPermission with scope :all) -- ✅ Can destroy users (HasPermission with scope :all) - -**AshAuthentication:** -- ✅ Registration works without actor -- ✅ OIDC registration works -- ✅ OIDC sign-in works - -**Test Environment:** -- ✅ Operations without actor work in test environment -- ✅ All tests explicitly use system_actor for authorization - ---- - -## Files Changed - -### Implementation -1. ✅ `lib/accounts/user.ex` - Added policies block (lines 271-315) -2. ✅ `lib/mv/authorization/checks/has_permission.ex` - Added User resource support in `evaluate_filter_for_strict_check` - -### Tests -3. ✅ `test/mv/accounts/user_policies_test.exs` - Created comprehensive test suite (435 lines) -4. ✅ `test/mv/authorization/checks/has_permission_test.exs` - Updated to expect `false` instead of `:unknown` - -### Documentation -5. ✅ `docs/policy-bypass-vs-haspermission.md` - New comprehensive guide (created) -6. ✅ `docs/roles-and-permissions-architecture.md` - Updated User and Member sections -7. ✅ `docs/roles-and-permissions-implementation-plan.md` - Marked Issue #8 as completed -8. ✅ `docs/user-resource-policies-implementation-summary.md` - This file (created) - ---- - -## Lessons Learned - -### 1. Test Before Assuming - -The initial plan assumed HasPermission with `scope :own` would be sufficient. Testing revealed that Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check` returns `false` or `:unknown`. - -### 2. Bypass Is Not a Workaround, It's a Pattern - -The bypass with `expr()` is not a hack or workaround - it's the **correct pattern** for filter-based authorization in Ash when dealing with list queries. - -### 3. Scope Concept Remains Essential - -Even with bypass for READ, the scope concept in PermissionSets is essential for: -- UPDATE/CREATE/DESTROY operations -- Documentation and maintainability -- Centralized permission management - -### 4. Consistency Across Resources - -Following the same pattern (Bypass for READ, HasPermission for UPDATE) across User and Member resources makes the codebase more maintainable and predictable. - -### 5. Documentation Is Key - -Thorough documentation explaining **WHY** the pattern exists prevents future confusion and ensures the pattern is applied correctly in future resources. - ---- - -## Future Considerations - -### If Adding New Resources with Filter-Based Permissions - -Follow the same pattern: -1. Bypass with `expr()` for READ (list queries) -2. HasPermission for UPDATE/CREATE/DESTROY (uses scope from PermissionSets) -3. Define appropriate scopes in PermissionSets (`:own`, `:linked`, `:all`) - -### If Ash Framework Changes - -If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown`: -1. Consider removing bypass for READ -2. Keep only HasPermission policy -3. Update tests to verify new behavior -4. Update documentation - -**For now (Ash 3.13.1), the current pattern is correct and necessary.** - ---- - -## Conclusion - -✅ **User Resource Authorization Policies are fully implemented, tested, and documented.** - -The implementation: -- Follows best practices for Ash policies -- Is consistent with Member resource pattern -- Uses the scope concept from PermissionSets effectively -- Has comprehensive test coverage -- Is thoroughly documented for future developers - -**Status: PRODUCTION READY** 🎉 From 5d8f1735291df7bc9cf8414aa67131a7e239d9a3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 21:53:36 +0200 Subject: [PATCH 30/63] docs(membership): condense membership, onboarding and import docs and align with the code --- docs/csv-member-import-v1.md | 918 +++++----------------------- docs/membership-fee-architecture.md | 714 +++------------------- docs/membership-fee-overview.md | 403 +++--------- docs/onboarding-join-concept.md | 305 ++++----- 4 files changed, 436 insertions(+), 1904 deletions(-) diff --git a/docs/csv-member-import-v1.md b/docs/csv-member-import-v1.md index 1a717c6..9f4fe8c 100644 --- a/docs/csv-member-import-v1.md +++ b/docs/csv-member-import-v1.md @@ -1,796 +1,172 @@ -# CSV Member Import v1 - Implementation Plan +# CSV Member Import -**Version:** 1.0 -**Last Updated:** 2026-01-13 -**Status:** In Progress (Backend Complete, UI Complete, Tests Pending) -**Related Documents:** -- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning +Reference for how the CSV member import actually behaves. The end-to-end +LiveView test (`test/mv_web/live/import_live_test.exs`) and future maintenance +depend on the rules documented here. -## Implementation Status +**Status:** implemented (backend + LiveView UI). -**Completed Issues:** -- ✅ Issue #1: CSV Specification & Static Template Files -- ✅ Issue #2: Import Service Module Skeleton -- ✅ Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling -- ✅ Issue #4: Header Normalization + Per-Header Mapping -- ✅ Issue #5: Validation (Required Fields) + Error Formatting -- ✅ Issue #6: Persistence via Ash Create + Per-Row Error Capture (with Error-Capping) -- ✅ Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links) -- ✅ Issue #8: Authorization + Limits -- ✅ Issue #11: Custom Field Import (Backend + UI) +Implementation: -**In Progress / Pending:** -- ⏳ Issue #9: End-to-End LiveView Tests + Fixtures -- ⏳ Issue #10: Documentation Polish +- `lib/mv/membership/import/csv_parser.ex` — BOM stripping, delimiter detection, physical line numbering +- `lib/mv/membership/import/header_mapper.ex` — header normalization + column mapping +- `lib/mv/membership/import/column_resolver.ex` — read-only resolution of groups + fee-type columns (preview) +- `lib/mv/membership/import/member_csv.ex` — `prepare/2`, `process_chunk/4`, validation, member creation +- `lib/mv/membership/import/import_runner.ex` — orchestration glue +- `lib/mv_web/live/import_live.ex` (+ `import_live/components.ex`) — UI, state machine, chunk driving +- `lib/mv_web/controllers/import_template_controller.ex` — on-the-fly template generation -**Latest Update:** CSV Import UI fully implemented in GlobalSettingsLive with chunk processing, progress tracking, error display, and custom field support (2026-01-13) +## Scope ---- +Admin-only bulk creation of members from an uploaded CSV. -## Table of Contents +- **Create only** — no upsert/update of existing members. +- **No deduplication** — a duplicate email fails its row (unique constraint) and is reported as an error. +- **Best-effort, row-by-row** — no transactional rollback; a failed row does not abort the import. +- **No background jobs** — progress is driven via LiveView `handle_info` chunk messages. +- **Errors shown in UI only** — no error-CSV export. -- [Overview & Scope](#overview--scope) -- [UX Flow](#ux-flow) -- [CSV Specification](#csv-specification) -- [Technical Design Notes](#technical-design-notes) -- [Implementation Issues](#implementation-issues) -- [Rollout & Risks](#rollout--risks) +Out of scope: upsert, mapping wizard, transactional all-or-nothing, error export, import history/audit. ---- +## UI Flow -## Overview & Scope +- **Route:** `/admin/import` (LiveView `MvWeb.ImportLive`). Template downloads: + `/admin/import/template/en` and `/admin/import/template/de` (dynamic controller, not static files). +- **Authorization:** requires `can?(:create, Mv.Membership.Member)`. Non-admins are + redirected with a "don't have permission" flash. The import section, the template + controller, and the `start_import` event all enforce this. +- **Upload:** `allow_upload(:csv_file, accept: .csv, max_entries: 1, auto_upload: true)`. + File size limit enforced by `max_file_size`. +- **State machine** (`@import_status`): `idle → preview → running → done|error`. + - **start_import** parses + resolves the file and transitions to **preview**. This step + is **read-only**: no members are created yet. The preview shows the column mapping, + sample rows, groups that exist vs. would be created, and fee-type/unknown-column warnings. + - **confirm_import** begins processing and creates members chunk by chunk. +- **Results:** success count, failure count, error list (each with CSV line number, message, + optional field), warnings, and a truncation notice when errors exceed the cap. -### What We're Building +## Limits -A **basic CSV member import feature** that allows administrators to upload a CSV file and import new members into the system. This is a **v1 minimal implementation** focused on establishing the import structure without advanced features. +- **Max file size:** configurable via `config :mv, csv_import: [max_file_size_mb: ...]` (enforced by `allow_upload`). +- **Max rows:** configurable via `config :mv, csv_import: [max_rows: ...]`, default **1000**, excluding header. Enforced in `MemberCSV.prepare/2`; exceeding it yields an error containing `"exceeds"`. +- **Chunk size:** 200 rows per chunk. +- **Error cap:** 50 errors collected per import overall (`failed` count stays accurate; `errors_truncated?` flag set when exceeded). -**Core Functionality (v1 Minimal):** -- Upload CSV file via LiveView file upload -- Parse CSV with bilingual header support for core member fields (English/German) -- Auto-detect delimiter (`;` or `,`) using header recognition -- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`, `country`) -- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning) -- Validate each row (required field: `email`) -- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages) -- Display import results: success count, error count, and error details -- Provide static CSV templates (EN/DE) +## Parsing (`CsvParser.parse/1`) -**Key Constraints (v1):** -- ✅ **Admin-only feature** -- ✅ **No upsert** (create only) -- ✅ **No deduplication** (duplicate emails fail and show as errors) -- ✅ **No mapping wizard** (fixed header mapping via bilingual variants) -- ✅ **No background jobs** (progress via LiveView `handle_info`) -- ✅ **Best-effort import** (row-by-row, no rollback) -- ✅ **UI-only error display** (no error CSV export) -- ✅ **Safety limits** (10 MB, 1,000 rows, chunks of 200) +- Content must be **valid UTF-8** (else error). Empty content / empty header row are errors. +- **UTF-8 BOM is stripped first**, before any header handling. +- Line endings normalized: `\r\n`, `\r`, `\n` all handled. +- **Delimiter auto-detection:** parse the header with both `;` and `,` parsers (NimbleCSV, + quote-aware), count non-empty fields each yields, pick the higher; **`;` wins ties**; + default `;`. +- **Quoting:** double-quote quoting; `""` inside a quoted field is a literal `"`. Newlines + inside quoted fields are supported — the record keeps its **start** line number. +- **Physical line numbers:** rows are returned as `{csv_line_number, values}` where the line + number is the physical 1-based line in the file (header is line 1, first data row is line 2). + **Empty lines are skipped but do not shift numbering** — downstream code must use the + parser's line numbers, never recompute from row index. (Test asserts an invalid row after a + skipped empty line still reports its true physical line, e.g. `Line 4`.) +- Completely empty rows are skipped. An unparsable row produces an error naming its line number. -### Out of Scope (v1) +## Header Mapping & Normalization (`HeaderMapper`) -**Deferred to Future Versions:** -- ❌ Upsert/update existing members -- ❌ Advanced deduplication strategies -- ❌ Column mapping wizard UI -- ❌ Background job processing (Oban/GenStage) -- ❌ Transactional all-or-nothing import -- ❌ Error CSV export/download -- ❌ Batch validation preview before import -- ❌ Dynamic template generation -- ❌ Import history/audit log -- ❌ Import templates for other entities +**`normalize_header/1`** (applied identically to incoming headers, mapping variants, custom +field names, group names, and fee-type names): ---- +1. trim, lowercase +2. transliterate German chars: `ß → ss`, `ä → ae`, `ö → oe`, `ü → ue` (and uppercase forms) +3. unify hyphen variants (en dash U+2013, em dash U+2014, minus U+2212 → `-`) +4. punctuation to spaces: `_`, `()[]{}`, `/`, `\` → space +5. **remove all whitespace** (so `first name` == `firstname`) +6. final trim -## UX Flow +Matching is on the fully normalized string. -### Access & Location +**Required field:** `email`. Missing it aborts `prepare` with a "Missing required header" error. -**Entry Point:** -- **Location:** Global Settings page (`/settings`) -- **UI Element:** New section "Import Members (CSV)" below "Custom Fields" section -- **Access Control:** Admin-only (enforced at LiveView event level, not entire `/settings` route) +**Unknown member-field columns:** ignored (no error). If an unknown column looks like it +could be a custom field that does not exist, a **warning** is emitted (import continues). -### User Journey +**Duplicate headers** mapping to the same canonical field (or same custom field) are an error. -1. **Navigate to Global Settings** -2. **Access Import Section** - - **Important notice:** Custom fields should be created in Mila before importing CSV files with custom field columns (unknown columns will be ignored with a warning) - - Upload area (drag & drop or file picker) - - Template download links (English / German) - - Help text explaining CSV format and custom field requirements -3. **Ensure Custom Fields Exist (if importing custom fields)** - - Navigate to Custom Fields section and create required custom fields - - Note the name/identifier for each custom field (used as CSV header) -4. **Download Template (Optional)** -5. **Prepare CSV File** - - Include custom field columns using the custom field name as header (e.g., `membership_number`, `birth_date`) -6. **Upload CSV** -7. **Start Import** - - Runs server-side via LiveView messages (may take up to ~30 seconds for large files) - - Warning messages if custom field columns reference non-existent custom fields (columns will be ignored) -8. **View Results** - - Success count - - Error count - - First 50 errors, each with: - - **CSV line number** (header is line 1, first data record begins at line 2) - - Error message - - Field name (if applicable) +### Supported member fields and header variants -### Error Handling +Source of truth is `@member_field_variants_raw` in `header_mapper.ex`. Variants below are +illustrative; matching is via normalization, so casing/hyphen/whitespace differences all collapse. -- **File too large:** Flash error before upload starts -- **Too many rows:** Flash error before import starts -- **Invalid CSV format:** Error shown in results -- **Partial success:** Results show both success and error counts - ---- - -## CSV Specification - -### Delimiter - -**Recommended:** Semicolon (`;`) -**Supported:** `;` and `,` - -**Auto-Detection (Header Recognition):** -- Remove UTF-8 BOM *first* -- Extract header record and try parsing with both delimiters -- For each delimiter, count how many recognized headers are present (via normalized variants) -- Choose delimiter with higher recognition; prefer `;` if tied -- If neither yields recognized headers, default to `;` - -### Quoting Rules - -- Fields may be quoted with double quotes (`"`) -- Escaped quotes: `""` inside quoted field represents a single `"` -- **v1 assumption:** CSV records do **not** contain embedded newlines inside quoted fields. (If they do, parsing may fail or line numbers may be inaccurate.) - -### Column Headers - -**v1 Supported Fields:** - -**Core Member Fields (all importable):** -- `email` / `E-Mail` (required) -- `first_name` / `Vorname` (optional) -- `last_name` / `Nachname` (optional) -- `join_date` / `Beitrittsdatum` (optional, ISO-8601 date) -- `exit_date` / `Austrittsdatum` (optional, ISO-8601 date) -- `notes` / `Notizen` (optional) -- `country` / `Land` / `Staat` (optional) -- `city` / `Stadt` (optional) -- `street` / `Straße` (optional) -- `house_number` / `Hausnummer` / `Nr.` (optional) -- `postal_code` / `PLZ` / `Postleitzahl` (optional) -- `membership_fee_start_date` / `Beitragsbeginn` (optional, ISO-8601 date) - -Address column order in import/export matches the members overview: country, city, street, house number, postal code. - -**Not supported for import (by design):** -- **membership_fee_status** – Computed field (from fee cycles). Not stored; export-only. -- **groups** – Many-to-many relationship. Would require resolving group names to IDs; not in current scope. -- **membership_fee_type_id** – Foreign key; could be added later (e.g. resolve type name to ID). - -**Custom Fields:** -- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`) -- **Important:** Custom fields must be created in Mila before importing. The CSV header must match the custom field name exactly (same normalization as member fields). -- **Behavior:** If the CSV contains custom field columns that don't exist in Mila, a warning message will be shown and those columns will be ignored during import. -- **Value Validation:** Custom field values are validated according to the custom field type: - - **string**: Any text value (trimmed) - - **integer**: Must be a valid integer (e.g., `42`, `-10`). Invalid values will cause a row error with the custom field name and reason. - - **boolean**: Accepts `true`, `false`, `1`, `0`, `yes`, `no`, `ja`, `nein` (case-insensitive). Invalid values will cause a row error. - - **date**: Must be in ISO-8601 format (YYYY-MM-DD, e.g., `2024-01-15`). Invalid values will cause a row error. - - **email**: Must be a valid email format (contains `@`, 5-254 characters, valid format). Invalid values will cause a row error. -- **Error Messages:** Custom field validation errors are included in the import error list with format: `custom_field: ` (e.g., `custom_field: Alter – expected integer, got: abc`) - -**Member Field Header Mapping:** - -| Canonical Field | English Variants | German Variants | +| Canonical | Example accepted headers (EN / DE) | Notes | |---|---|---| -| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` | -| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` | -| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` | -| `join_date` | `join date`, `join_date` | `Beitrittsdatum`, `beitritts-datum` | -| `exit_date` | `exit date`, `exit_date` | `Austrittsdatum`, `austritts-datum` | -| `notes` | `notes` | `Notizen`, `bemerkungen` | -| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` | -| `house_number` | `house number`, `house_number`, `house no` | `Hausnummer`, `Nr`, `Nr.`, `Nummer` | -| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` | -| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` | -| `country` | `country` | `Land`, `land`, `Staat`, `staat` | -| `membership_fee_start_date` | `membership fee start date`, `membership_fee_start_date`, `fee start` | `Beitragsbeginn`, `beitrags-beginn` | - -**Header Normalization (used consistently for both input headers AND mapping variants):** -- Trim whitespace -- Convert to lowercase -- Normalize Unicode: `ß` → `ss` (e.g., `Straße` → `strasse`) -- Replace hyphens/whitespace with underscores: `E-Mail` → `e_mail`, `phone number` → `phone_number` -- Collapse multiple underscores: `e__mail` → `e_mail` -- Case-insensitive matching - -**Unknown columns:** ignored (no error) - -**Required fields:** `email` - -**Custom Field Columns:** -- Custom field columns are identified by matching the normalized CSV header to the custom field `name` (not slug) -- Same normalization rules apply as for member fields (trim, lowercase, Unicode normalization, underscore replacement) -- Unknown custom field columns (non-existent names) will be ignored with a warning message - -### CSV Template Files - -**Location:** -- `priv/static/templates/member_import_en.csv` -- `priv/static/templates/member_import_de.csv` - -**Content:** -- Header row with required + common optional fields -- **Note:** Custom field columns are not included in templates by default (users add them based on their custom field configuration) -- One example row -- Uses semicolon delimiter (`;`) -- UTF-8 encoding **with BOM** (Excel compatibility) - -**Template Access:** -- Templates are static files in `priv/static/templates/` -- Served at: - - `/templates/member_import_en.csv` - - `/templates/member_import_de.csv` -- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version). - -**Example Usage in LiveView Templates:** - -```heex - -<.link href={~p"/templates/member_import_en.csv"} download> - <%= gettext("Download English Template") %> - - -<.link href={~p"/templates/member_import_de.csv"} download> - <%= gettext("Download German Template") %> - - - -<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download> - <%= gettext("Download English Template") %> - -``` - -**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served. - -### File Limits - -- **Max file size:** 10 MB -- **Max rows:** 1,000 rows (excluding header) -- **Processing:** chunks of 200 (via LiveView messages) -- **Encoding:** UTF-8 (BOM handled) - ---- - -## Technical Design Notes - -### Architecture Overview - -``` -┌─────────────────┐ -│ LiveView UI │ (GlobalSettingsLive or component) -│ - Upload area │ -│ - Progress │ -│ - Results │ -└────────┬────────┘ - │ prepare - ▼ -┌─────────────────────────────┐ -│ Import Service │ (Mv.Membership.Import.MemberCSV) -│ - parse + map + limit checks│ -> returns import_state -│ - process_chunk(chunk) │ -> returns chunk results -└────────┬────────────────────┘ - │ create - ▼ -┌─────────────────┐ -│ Ash Resource │ (Mv.Membership.Member) -│ - Create │ -└─────────────────┘ -``` - -### Technology Stack - -- **Phoenix LiveView:** file upload via `allow_upload/3` -- **NimbleCSV:** CSV parsing (add explicit dependency if missing) -- **Ash Resource:** member creation via `Membership.create_member/1` -- **Gettext:** bilingual UI/error messages - -### Module Structure - -**New Modules:** -- `lib/mv/membership/import/member_csv.ex` - import orchestration + chunk processing + custom field handling -- `lib/mv/membership/import/csv_parser.ex` - delimiter detection + parsing + BOM handling -- `lib/mv/membership/import/header_mapper.ex` - normalization + header mapping (core fields + custom fields) - -**Modified Modules:** -- `lib/mv_web/live/global_settings_live.ex` - render import section, handle upload/events/messages - -### Data Flow - -1. **Upload:** LiveView receives file via `allow_upload` -2. **Consume:** `consume_uploaded_entries/3` reads file content -3. **Prepare:** `MemberCSV.prepare/2` - - Strip BOM - - Detect delimiter (header recognition) - - Parse header + rows - - Map headers to canonical fields (core member fields) - - **Query existing custom fields and map custom field columns by name** (using same normalization as member fields) - - **Warn about unknown custom field columns** (non-existent names will be ignored with warning) - - Early abort if required headers missing - - Row count check - - Return `import_state` containing chunks, column_map, and custom_field_map -4. **Process:** LiveView drives chunk processing via `handle_info` - - For each chunk: validate + create member + create custom field values + collect errors -5. **Results:** LiveView shows progress + final summary - -### Types & Key Consistency - -- **Raw CSV parsing:** returns headers as list of strings, and rows **with csv line numbers** -- **Header mapping:** operates on normalized strings; mapping table variants are normalized once -- **Ash attrs:** built as atom-keyed map (`%{first_name: ..., ...}`) - -### Error Model - -```elixir -%{ - csv_line_number: 5, # physical line number in the CSV file - field: :email, # optional - message: "is not a valid email" -} -``` - -### CSV Line Numbers (Important) - -To keep error reporting user-friendly and accurate, **row errors must reference the physical line number in the original file**, even if empty lines are skipped. - -**Design decision:** the parser returns rows as: - -```elixir -rows :: [{csv_line_number :: pos_integer(), row_map :: map()}] -``` - -Downstream logic must **not** recompute line numbers from row indexes. - -### Authorization - -**Enforcement points:** -1. **LiveView event level:** check admin permission in `handle_event("start_import", ...)` -2. **UI level:** render import section only for admin users -3. **Static templates:** public assets (no authorization needed) - -Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string checks where possible. - -### Safety Limits - -- File size enforced by `allow_upload` (`max_file_size`) -- Row count enforced in `MemberCSV.prepare/2` before processing starts -- Chunking is done via **LiveView `handle_info` loop** (sequential, cooperative scheduling) - ---- - -## Implementation Issues - -### Issue #1: CSV Specification & Static Template Files - -**Dependencies:** None - -**Status:** ✅ **COMPLETED** - -**Goal:** Define CSV contract and add static templates. - -**Tasks:** -- [x] Finalize header mapping variants -- [x] Document normalization rules -- [x] Document delimiter detection strategy -- [x] Create templates in `priv/static/templates/` (UTF-8 with BOM) - - `member_import_en.csv` with English headers - - `member_import_de.csv` with German headers -- [x] Document template URLs and how to link them from LiveView -- [x] Document line number semantics (physical CSV line numbers) -- [x] Templates included in `MvWeb.static_paths()` configuration - -**Definition of Done:** -- [x] Templates open cleanly in Excel/LibreOffice -- [x] CSV spec section complete - ---- - -### Issue #2: Import Service Module Skeleton - -**Dependencies:** None - -**Status:** ✅ **COMPLETED** - -**Goal:** Create service API and error types. - -**API (recommended):** -- `prepare/2` — parse + map + limit checks, returns import_state -- `process_chunk/4` — process one chunk (pure-ish), returns per-chunk results - -**Tasks:** -- [x] Create `lib/mv/membership/import/member_csv.ex` -- [x] Define public function: `prepare/2 (file_content, opts \\ [])` -- [x] Define public function: `process_chunk/4 (chunk_rows_with_lines, column_map, custom_field_map, opts \\ [])` -- [x] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}` -- [x] Document module + API - ---- - -### Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling - -**Dependencies:** Issue #2 - -**Status:** ✅ **COMPLETED** - -**Goal:** Parse CSV robustly with correct delimiter detection and BOM handling. - -**Tasks:** -- [x] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`) -- [x] Create `lib/mv/membership/import/csv_parser.ex` -- [x] Implement `strip_bom/1` and apply it **before** any header handling -- [x] Handle `\r\n` and `\n` line endings (trim `\r` on header record) -- [x] Detect delimiter via header recognition (try `;` and `,`) -- [x] Parse CSV and return: - - `headers :: [String.t()]` - - `rows :: [{csv_line_number, [String.t()]}]` with correct physical line numbers -- [x] Skip completely empty records (but preserve correct physical line numbers) -- [x] Return `{:ok, headers, rows}` or `{:error, reason}` - -**Definition of Done:** -- [x] BOM handling works (Excel exports) -- [x] Delimiter detection works reliably -- [x] Rows carry correct `csv_line_number` - ---- - -### Issue #4: Header Normalization + Per-Header Mapping (No Language Detection) - -**Dependencies:** Issue #3 - -**Status:** ✅ **COMPLETED** - -**Goal:** Map each header individually to canonical fields (normalized comparison). - -**Tasks:** -- [x] Create `lib/mv/membership/import/header_mapper.ex` -- [x] Implement `normalize_header/1` -- [x] Normalize mapping variants once and compare normalized strings -- [x] Build `column_map` (canonical field -> column index) -- [x] **Early abort if required headers missing** (`email`) -- [x] Ignore unknown columns (member fields only) -- [x] **Separate custom field column detection** (by name, with normalization) - -**Definition of Done:** -- [x] English/German headers map correctly -- [x] Missing required columns fails fast - ---- - -### Issue #5: Validation (Required Fields) + Error Formatting - -**Dependencies:** Issue #4 - -**Status:** ✅ **COMPLETED** - -**Goal:** Validate each row and return structured, translatable errors. - -**Tasks:** -- [x] Implement `validate_row/3 (row_map, csv_line_number, opts)` -- [x] Required field presence (`email`) -- [x] Email format validation (EctoCommons.EmailValidator) -- [x] Trim values before validation -- [x] Gettext-backed error messages - ---- - -### Issue #6: Persistence via Ash Create + Per-Row Error Capture (Chunked Processing) - -**Dependencies:** Issue #5 - -**Status:** ✅ **COMPLETED** - -**Goal:** Create members and capture errors per row with correct CSV line numbers. - -**Tasks:** -- [x] Implement `process_chunk/4` in service: - - Input: `[{csv_line_number, row_map}]` - - Validate + create sequentially - - Collect counts + first 50 errors (per import overall; LiveView enforces cap across chunks) - - **Error-Capping:** Supports `existing_error_count` and `max_errors` in opts (default: 50) - - **Error-Capping:** Only collects errors if under limit, but continues processing all rows - - **Error-Capping:** `failed` count is always accurate, even when errors are capped -- [x] Implement Ash error formatter helper: - - Convert `Ash.Error.Invalid` into `%MemberCSV.Error{}` - - Prefer field-level errors where possible (attach `field` atom) - - Handle unique email constraint error as user-friendly message -- [x] Map row_map to Ash attrs (`%{first_name: ..., ...}`) -- [x] Custom field value processing and creation - -**Important:** **Do not recompute line numbers** in this layer—use the ones provided by the parser. - -**Implementation Notes:** -- `process_chunk/4` accepts `opts` with `existing_error_count` and `max_errors` for error capping across chunks -- Error capping respects the limit per import overall (not per chunk) -- Processing continues even after error limit is reached (for accurate counts) - ---- - -### Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links) - -**Dependencies:** Issue #6 - -**Status:** ✅ **COMPLETED** - -**Goal:** UI section with upload, progress, results, and template links. - -**Tasks:** -- [x] Render import section only for admins -- [x] **Add prominent UI notice about custom fields:** - - Display alert/info box: "Custom fields must be created in Mila before importing CSV files with custom field columns" - - Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)" - - Add link to custom fields management section -- [x] Configure `allow_upload/3`: - - `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: true` (auto-upload enabled for better UX) -- [x] `handle_event("start_import", ...)`: - - Admin permission check - - Consume upload -> read file content - - Call `MemberCSV.prepare/2` - - Store `import_state` in assigns (chunks + column_map + metadata) - - Initialize progress assigns - - `send(self(), {:process_chunk, 0})` -- [x] `handle_info({:process_chunk, idx}, socket)`: - - Fetch chunk from `import_state` - - Call `MemberCSV.process_chunk/4` with error capping support - - Merge counts/errors into progress assigns (cap errors at 50 overall) - - Schedule next chunk (or finish and show results) - - Async task processing with SQL sandbox support for tests -- [x] Results UI: - - Success count - - Failure count - - Error list (line number + message + field) - - **Warning messages for unknown custom field columns** (non-existent names) shown in results - - Progress indicator during import - - Error truncation notice when errors exceed limit - -**Template links:** -- [x] Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers. - -**Definition of Done:** -- [x] Upload area with drag & drop support -- [x] Template download links (EN/DE) -- [x] Progress tracking during import -- [x] Results display with success/error counts -- [x] Error list with line numbers and field information -- [x] Warning display for unknown custom field columns -- [x] Admin-only access control -- [x] Async chunk processing with proper error handling - ---- - -### Issue #8: Authorization + Limits - -**Dependencies:** None (can be parallelized) - -**Status:** ✅ **COMPLETED** - -**Goal:** Ensure admin-only access and enforce limits. - -**Tasks:** -- [x] Admin check in start import event handler (via `Authorization.can?/3`) -- [x] File size enforced in upload config (`max_file_size: 10MB`) -- [x] Row limit enforced in `MemberCSV.prepare/2` (max_rows: 1000, configurable via opts) -- [x] Chunk size limit (200 rows per chunk) -- [x] Error limit (50 errors per import) -- [x] UI-level authorization check (import section only visible to admins) -- [x] Event-level authorization check (prevents unauthorized import attempts) - -**Implementation Notes:** -- File size limit: 10 MB (10,485,760 bytes) enforced via `allow_upload/3` -- Row limit: 1,000 rows (excluding header) enforced in `MemberCSV.prepare/2` -- Chunk size: 200 rows per chunk (configurable via opts) -- Error limit: 50 errors per import (configurable via `@max_errors`) -- Authorization uses `MvWeb.Authorization.can?/3` with `:create` permission on `Mv.Membership.Member` - -**Definition of Done:** -- [x] Admin-only access enforced at UI and event level -- [x] File size limit enforced -- [x] Row count limit enforced -- [x] Chunk processing with size limits -- [x] Error capping implemented - ---- - -### Issue #9: End-to-End LiveView Tests + Fixtures - -**Dependencies:** Issue #7 and #8 - -**Tasks:** -- [ ] Fixtures: - - valid EN/DE (core fields only) - - valid with custom fields - - invalid - - unknown custom field name (non-existent, should show warning) - - too many rows (1,001) - - BOM + `;` delimiter fixture - - fixture with empty line(s) to validate correct line numbers -- [ ] LiveView tests: - - admin sees section, non-admin does not - - upload + start import - - success + error rendering - - row limit + file size errors - - custom field import success - - custom field import warning (non-existent name, column ignored) - ---- - -### Issue #10: Documentation Polish (Inline Help Text + Docs) - -**Dependencies:** Issue #9 - -**Tasks:** -- [ ] UI help text + translations -- [ ] CHANGELOG entry -- [ ] Ensure moduledocs/docs - ---- - -### Issue #11: Custom Field Import - -**Dependencies:** Issue #6 (Persistence) - -**Priority:** High (Core v1 Feature) - -**Status:** ✅ **COMPLETED** (Backend + UI Implementation) - -**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results. - -**Important Requirements:** -- **Custom fields should be created in Mila first** - Unknown custom field columns will be ignored with a warning message -- CSV headers for custom fields must match the custom field **name** exactly (same normalization as member fields applies) -- Custom field values are validated according to the custom field type (string, integer, boolean, date, email) -- Unknown custom field columns (non-existent names) will be ignored with a warning - import continues - -**Tasks:** -- [x] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields) -- [x] Query existing custom fields during `prepare/2` to map custom field columns -- [x] Collect unknown custom field columns and add warning messages (don't fail import) -- [x] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/4` -- [x] Handle custom field type validation (string, integer, boolean, date, email) with proper error messages -- [x] Create `CustomFieldValue` records linked to members during import -- [x] Validate custom field values and return structured errors with custom field name and reason -- [x] UI help text and link to custom field management (implemented in Issue #7) -- [x] Update error messages to include custom field validation errors (format: `custom_field: – expected , got: `) -- [x] Add UI help text explaining custom field requirements (completed in Issue #7): - - "Custom fields must be created in Mila before importing" - - "Use the custom field name as the CSV column header (same normalization as member fields)" - - Link to custom fields management section -- [x] Update CSV templates documentation to explain custom field columns (documented in Issue #1) -- [x] Add tests for custom field import (valid, invalid name, type validation, warning for unknown) - -**Definition of Done:** -- [x] Custom field columns are recognized by name (with normalization) -- [x] Warning messages shown for unknown custom field columns (import continues) -- [x] Custom field values are created and linked to members -- [x] Type validation works for all custom field types (string, integer, boolean, date, email) -- [x] UI clearly explains custom field requirements (completed in Issue #7) -- [x] Tests cover custom field import scenarios (including warning for unknown names) -- [x] Error messages include custom field validation errors with proper formatting - -**Implementation Notes:** -- Custom field lookup is built in `prepare/2` and passed via `custom_field_lookup` in opts -- Custom field values are formatted according to type in `format_custom_field_value/2` -- Unknown custom field columns generate warnings in `import_state.warnings` - ---- - -## Rollout & Risks - -### Rollout Strategy -- Dev → Staging → Production (with anonymized real-world CSV tests) - -### Risks & Mitigations - -| Risk | Impact | Likelihood | Mitigation | -|---|---:|---:|---| -| Large import timeout | High | Medium | 10 MB + 1,000 rows, chunking via `handle_info` | -| Encoding issues | Medium | Medium | BOM stripping, templates with BOM | -| Invalid CSV format | Medium | High | Clear errors + templates | -| Duplicate emails | Low | High | Ash constraint error -> user-friendly message | -| Performance (no background jobs) | Medium | Low | Small limits, sequential chunk processing | -| Admin access bypass | High | Low | Event-level auth + UI hiding | -| Data corruption | High | Low | Per-row validation + best-effort | - ---- - -## Appendix - -### Module File Structure - -``` -lib/ -├── mv/ -│ └── membership/ -│ └── import/ -│ ├── member_csv.ex # prepare + process_chunk -│ ├── import_runner.ex # orchestration: file read, progress merge, chunk process, error format -│ ├── csv_parser.ex # delimiter detection + parsing + BOM handling -│ └── header_mapper.ex # normalization + header mapping -└── mv_web/ - └── live/ - ├── import_export_live.ex # mount / handle_event / handle_info + glue only - └── import_export_live/ - └── components.ex # UI: custom_fields_notice, template_links, import_form, import_progress, import_results - -priv/ -└── static/ - └── templates/ - ├── member_import_en.csv - └── member_import_de.csv - -test/ -├── mv/ -│ └── membership/ -│ └── import/ -│ ├── member_csv_test.exs -│ ├── csv_parser_test.exs -│ └── header_mapper_test.exs -└── fixtures/ - ├── member_import_en.csv - ├── member_import_de.csv - ├── member_import_invalid.csv - ├── member_import_large.csv - └── member_import_empty_lines.csv -``` - -### Example Usage (LiveView) - -```elixir -def handle_event("start_import", _params, socket) do - assert_admin!(socket.assigns.current_user) - - [{_name, content}] = - consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry -> - {:ok, File.read!(path)} - end) - - case Mv.Membership.Import.MemberCSV.prepare(content) do - {:ok, import_state} -> - socket = - socket - |> assign(:import_state, import_state) - |> assign(:import_progress, %{processed: 0, inserted: 0, failed: 0, errors: []}) - |> assign(:importing?, true) - - send(self(), {:process_chunk, 0}) - {:noreply, socket} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, reason)} - end -end - -def handle_info({:process_chunk, idx}, socket) do - %{chunks: chunks, column_map: column_map} = socket.assigns.import_state - - case Enum.at(chunks, idx) do - nil -> - {:noreply, assign(socket, importing?: false)} - - chunk_rows_with_lines -> - {:ok, chunk_result} = - Mv.Membership.Import.MemberCSV.process_chunk(chunk_rows_with_lines, column_map) - - socket = merge_progress(socket, chunk_result) # caps errors at 50 overall - - send(self(), {:process_chunk, idx + 1}) - {:noreply, socket} - end -end -``` - ---- - -**End of Implementation Plan** +| `email` (required) | email, e-mail, e_mail, mail, e-mail-adresse / E-Mail | | +| `first_name` | first name, firstname / Vorname | | +| `last_name` | last name, lastname, surname / Nachname, Familienname | | +| `join_date` | join date / Beitrittsdatum | ISO-8601 date | +| `exit_date` | exit date / Austrittsdatum | ISO-8601 date | +| `notes` | notes / Notizen, Bemerkungen | | +| `street` | street, address / Straße, Strasse | | +| `house_number` | house number, house no / Hausnummer, Nr, Nr., Nummer | | +| `postal_code` | postal code, zip, postcode / PLZ, Postleitzahl | | +| `city` | city, town / Stadt, Ort | | +| `country` | country / Land, Staat | | +| `membership_fee_start_date` | membership fee start date, fee start / Beitragsbeginn | ISO-8601 date | + +### Special relationship columns + +- **groups** (headers `Groups` / `Gruppen` / `Gruppe`) — comma-separated group names. Names + matched case-insensitively against existing groups; **missing groups are auto-created** during + processing. A group-assignment failure fails that row (the member was already created). +- **membership_fee_type** (headers `Fee Type`, `fee_type`, `membership_fee_type` / `Beitragsart`) + — name matched to an existing `MembershipFeeType`. **Empty cell → default fee type** (no warning). + **Matched name → that fee type.** **Unmatched name → default fee type + warning** naming the value. + +These columns are resolved against the DB read-only in `prepare` (`ColumnResolver`) for the +preview; the actual writes happen in `process_chunk`. + +### Fields not importable (explicitly ignored) + +- **membership_fee_status** — computed from fee cycles; not stored. Fee-status header variants + (`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) and the DE export label + `Startdatum Mitgliedsbeitrag` are placed in the `ignored` list and never mapped. (The UI notice + names `Groups`/`Gruppen`, `Fee Type`/`Beitragsart`, and the always-ignored `Bezahlstatus`.) + +## Custom Fields + +- Custom field columns are matched by the custom field **name** (not slug), using the same + normalization. Member fields take priority on a name collision. +- **Custom fields must exist in Mila before import.** Unknown custom-field columns are ignored + with a warning; the import still runs. +- Empty custom-field cells create no value. Values are trimmed; type-validated per the custom + field's `value_type`: + - **string** — any text (trimmed). + - **integer** — must parse fully (`Integer.parse` with no remainder); e.g. `42`, `-10`. + - **boolean** — case-insensitive `true/false`, `1/0`, `yes/no`, `ja/nein`. + - **date** — ISO-8601 `YYYY-MM-DD`. + - **email** — validated with `EctoCommons.EmailValidator` (same checks as member email). +- A value failing type validation fails the row. Error message format: + `custom_field: – expected , got: ` (type label is the human-readable + `FieldTypes.label/1`, with format hints for boolean/date). + +## Validation & Member Creation (`process_chunk/4` → `process_row`) + +Per row: validate → create member → create custom-field values → assign groups. Sequential. + +- **Email** is required and format-validated (`EctoCommons.EmailValidator`, `Mv.Constants.email_validator_checks()`) on a trimmed value. All string member values are trimmed. +- **Date fields** (`join_date`, `exit_date`, `membership_fee_start_date`): empty/blank strings are converted to `nil` so Ash accepts them. +- Member created via `Mv.Membership.create_member/2`. Custom field values are passed as + `custom_field_values` (Ash union `_union_type`/`_union_value` format), omitted when none. +- **Errors** are `%MemberCSV.Error{csv_line_number, field, message}`: + - `csv_line_number` is the physical line (1-based); never recomputed in this layer. + - Validation errors get `field: :email`; Ash errors prefer the field-level error. + - **Duplicate email** (unique constraint) is surfaced as a friendly + `"email has already been taken"` message. +- **Error capping** (`max_errors`, default 50, tracked across chunks via `existing_error_count`): + once the cap is hit, no further errors are collected but **all rows are still processed** and + the `failed` count stays accurate; `errors_truncated?` is set and the UI shows a truncation notice. + +## Templates (`ImportTemplateController`) + +- Generated on the fly (not static files), gated by `can?(:create, Member)`. +- One header row: standard member columns (localized EN/DE) + `Groups`/`Gruppen` + + `Fee Type`/`Beitragsart` + **every existing custom field name** appended, then one example row. +- Semicolon-delimited, RFC-4180 quoting; fields run through `MembersCSV.safe_cell/1` to + neutralize spreadsheet formula injection (e.g. a custom-field name like `=HYPERLINK(...)`). diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md index 6c81169..d9af604 100644 --- a/docs/membership-fee-architecture.md +++ b/docs/membership-fee-architecture.md @@ -1,67 +1,23 @@ # Membership Fees - Technical Architecture -**Project:** Mila - Membership Management System -**Feature:** Membership Fee Management -**Version:** 1.0 -**Last Updated:** 2026-01-13 -**Status:** ✅ Implemented +**Feature:** Membership Fee Management — **Status:** Implemented + +Architectural decisions, patterns, module structure, and integration points (no concrete implementation details). + +**Related:** [membership-fee-overview.md](./membership-fee-overview.md) (business logic, worked examples, UI mockups), [database-schema-readme.md](./database-schema-readme.md), [database_schema.dbml](./database_schema.dbml). --- -## Purpose +## Core Design Decisions -This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. - -**Related Documents:** - -- [membership-fee-overview.md](./membership-fee-overview.md) - Business logic and requirements -- [database-schema-readme.md](./database-schema-readme.md) - Database documentation -- [database_schema.dbml](./database_schema.dbml) - Database schema definition - ---- - -## Table of Contents - -1. [Architecture Principles](#architecture-principles) -2. [Domain Structure](#domain-structure) -3. [Data Architecture](#data-architecture) -4. [Business Logic Architecture](#business-logic-architecture) -5. [Integration Points](#integration-points) -6. [Acceptance Criteria](#acceptance-criteria) -7. [Testing Strategy](#testing-strategy) -8. [Security Considerations](#security-considerations) -9. [Performance Considerations](#performance-considerations) - ---- - -## Architecture Principles - -### Core Design Decisions - -1. **Single Responsibility:** - - Each module has one clear responsibility - - Cycle generation separated from status management - - Calendar logic isolated in dedicated module - -2. **No Redundancy:** - - No `cycle_end` field (calculated from `cycle_start` + `interval`) - - No `interval_type` field (read from `membership_fee_type.interval`) - - Eliminates data inconsistencies - -3. **Immutability Where Important:** - - `membership_fee_type.interval` cannot be changed after creation - - Prevents complex migration scenarios - - Enforced via Ash change validation - -4. **Historical Accuracy:** - - `amount` stored per cycle for audit trail - - Enables tracking of membership fee changes over time - - Old cycles retain original amounts - -5. **Calendar-Based Cycles:** - - All cycles aligned to calendar boundaries - - Simplifies date calculations - - Predictable cycle generation +1. **No redundant fields:** + - No `cycle_end` field — calculated from `cycle_start` + `interval`. + - No `interval_type` field — read from `membership_fee_type.interval`. + - Eliminates data inconsistencies. +2. **Interval immutability:** `membership_fee_type.interval` cannot be changed after creation (enforced via an Ash validation in `Mv.MembershipFees.MembershipFeeType`, and the attribute is omitted from the update action's `accept` list). Prevents complex migration scenarios. +3. **Historical accuracy:** `amount` stored per cycle for audit trail — old cycles retain their original amount, so membership-fee changes over time stay traceable. +4. **Calendar-based cycles:** all cycles aligned to calendar boundaries; simplifies date math and makes generation predictable. +5. **Single responsibility:** cycle generation, status management, and calendar logic live in separate modules. --- @@ -69,25 +25,20 @@ This document defines the technical architecture for the Membership Fees system. ### Ash Domain: `Mv.MembershipFees` -**Purpose:** Encapsulates all membership fee-related resources and logic +Encapsulates all membership-fee resources and logic. **Resources:** -- `MembershipFeeType` - Membership fee type definitions (admin-managed) -- `MembershipFeeCycle` - Individual membership fee cycles per member +- `MembershipFeeType` — membership fee type definitions (admin-managed). +- `MembershipFeeCycle` — individual membership fee cycles per member. -**Public API:** -The domain exposes code interface functions: -- `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1` -- `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1` +**Public API** (code interface): `create/list/update/destroy_membership_fee_type`, `create/list/update/destroy_membership_fee_cycle`. -**Note:** In LiveViews, direct `Ash.read`, `Ash.create`, `Ash.update`, `Ash.destroy` calls are used with `domain: Mv.MembershipFees` instead of code interface functions. This is acceptable for LiveView forms that use `AshPhoenix.Form`. +**Note:** LiveViews use direct `Ash.read/create/update/destroy` with `domain: Mv.MembershipFees` instead of the code interface — acceptable for LiveView forms using `AshPhoenix.Form`. -**Extensions:** +The Member resource is extended with membership fee fields. -- Member resource extended with membership fee fields - -### Module Organization +### Module Map ``` lib/ @@ -96,636 +47,159 @@ lib/ │ ├── membership_fee_type.ex # MembershipFeeType resource │ ├── membership_fee_cycle.ex # MembershipFeeCycle resource │ └── changes/ -│ ├── prevent_interval_change.ex # Validates interval immutability -│ ├── set_membership_fee_start_date.ex # Auto-sets start date -│ └── validate_same_interval.ex # Validates interval match on type change +│ ├── set_membership_fee_start_date.ex # Auto-sets start date +│ └── validate_same_interval.ex # Validates interval match on type change ├── mv/ │ └── membership_fees/ -│ ├── cycle_generator.ex # Cycle generation algorithm -│ └── calendar_cycles.ex # Calendar cycle calculations +│ ├── cycle_generator.ex # Cycle generation algorithm +│ ├── cycle_generation_job.ex # Scheduled cycle generation job +│ └── calendar_cycles.ex # Calendar cycle calculations └── membership/ - └── member.ex # Extended with membership fee relationships + └── member.ex # Extended with membership fee relationships ``` ### Separation of Concerns -**Domain Layer (Ash Resources):** - -- Data validation -- Relationship management -- Policy enforcement -- Action definitions - -**Business Logic Layer (`Mv.MembershipFees`):** - -- Cycle generation algorithm -- Calendar calculations -- Date boundary handling -- Status transitions - -**UI Layer (LiveView):** - -- User interaction -- Display logic -- Authorization checks -- Form handling +- **Domain layer (Ash resources):** data validation, relationships, policy enforcement, action definitions. +- **Business logic (`Mv.MembershipFees`):** cycle generation, calendar calculations, date boundaries, status transitions. +- **UI layer (LiveView):** interaction, display, authorization checks, form handling. --- ## Data Architecture -### Database Schema Extensions - -**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. +See [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema. ### New Tables -1. **`membership_fee_types`** - - Purpose: Define membership fee types with fixed intervals - - Key Constraint: `interval` field immutable after creation - - Relationships: has_many members, has_many membership_fee_cycles - -2. **`membership_fee_cycles`** - - Purpose: Individual membership fee cycles for members - - Key Design: NO `cycle_end` or `interval_type` fields (calculated) - - Relationships: belongs_to member, belongs_to membership_fee_type - - Composite uniqueness: One cycle per member per cycle_start +1. **`membership_fee_types`** — fee types with fixed `interval` (immutable after creation). has_many members, has_many membership_fee_cycles. +2. **`membership_fee_cycles`** — per-member cycles. NO `cycle_end`/`interval_type` (calculated). belongs_to member, belongs_to membership_fee_type. Composite uniqueness: one cycle per member per `cycle_start`. ### Member Table Extensions -**Fields Added:** - -- `membership_fee_type_id` (FK, NOT NULL with default from settings) +- `membership_fee_type_id` (FK, nullable — default applied from settings at the app level) - `membership_fee_start_date` (Date, nullable) -**Existing Fields Used:** - -- `join_date` - For calculating membership fee start -- `exit_date` - For limiting cycle generation -- These fields must remain member fields and should not be replaced by custom fields in the future +**Existing fields used:** `join_date` (computes membership fee start), `exit_date` (limits cycle generation). These must remain Member fields and should **not** be replaced by custom fields in the future. ### Settings Integration -**Global Settings:** - -- `membership_fees.include_joining_cycle` (Boolean) -- `membership_fees.default_membership_fee_type_id` (UUID) - -**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) +Global settings: `membership_fees.include_joining_cycle` (Boolean), `membership_fees.default_membership_fee_type_id` (UUID). Read during cycle generation and member creation; written only via admin UI. Validation: default fee type must exist. ### Foreign Key Behaviors | Relationship | On Delete | Rationale | |--------------|-----------|-----------| -| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted | -| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist | -| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members | +| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove cycles when member deleted | +| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if cycles exist | +| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if assigned to members | --- ## Business Logic Architecture -### Cycle Generation System +### Cycle Generation — `Mv.MembershipFees.CycleGenerator` -**Component:** `Mv.MembershipFees.CycleGenerator` +Calculates which cycles should exist for a member, generates the missing ones (idempotent — skips existing), respects `membership_fee_start_date` and `exit_date` boundaries, and uses **PostgreSQL advisory locks per member** to prevent race conditions. -**Responsibilities:** +**Triggers:** fee type assigned (Ash change); member created with fee type (Ash change); scheduled job (daily/weekly cron); admin manual regeneration (UI). -- Calculate which cycles should exist for a member -- Generate missing cycles -- Respect membership_fee_start_date and exit_date boundaries -- Skip existing cycles (idempotent) -- Use PostgreSQL advisory locks per member to prevent race conditions +**Algorithm:** -**Triggers:** - -1. Member membership fee type assigned (via Ash change) -2. Member created with membership fee type (via Ash change) -3. Scheduled job runs (daily/weekly cron) -4. Admin manual regeneration (UI action) - -**Algorithm Steps:** - -1. Retrieve member with membership fee type and dates +1. Retrieve member with fee type and dates. 2. Determine generation start point: - - If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`) - - If cycles exist: Start from the cycle AFTER the last existing one -3. Generate all cycle starts from the determined start point to today (or `exit_date`) -4. Create new cycles with current membership fee type's amount -5. Use PostgreSQL advisory locks per member to prevent race conditions + - No cycles exist → start from `membership_fee_start_date` (or calculated from `join_date`). + - Cycles exist → start from the cycle AFTER the last existing one. +3. Generate all cycle starts from that point to today (or `exit_date`). +4. Create new cycles with the current fee type's amount. -**Edge Case Handling:** +**Edge cases:** -- If membership_fee_start_date is NULL: Calculate from join_date + global setting -- If exit_date is set: Stop generation at exit_date -- If membership fee type changes: Handled separately by regeneration logic -- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated. - The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps. +- `membership_fee_start_date` NULL → calculate from `join_date` + global setting. +- `exit_date` set → stop generation at `exit_date`. +- Fee type changes → handled separately by regeneration logic. +- **Gap handling:** if cycles were explicitly deleted (gaps exist), they are **NOT** recreated. The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps. -### Calendar Cycle Calculations +### Calendar Cycles — `Mv.MembershipFees.CalendarCycles` -**Component:** `Mv.MembershipFees.CalendarCycles` +Calculates cycle boundaries by interval, the current cycle, the last completed cycle, and `cycle_end` from `cycle_start` + interval. -**Responsibilities:** +**Functions (high-level):** `calculate_cycle_start/2,3`, `calculate_cycle_end/2`, `next_cycle_start/2`, `current_cycle?/2,3`, `last_completed_cycle?/2,3`. -- Calculate cycle boundaries based on interval type -- Determine current cycle -- Determine last completed cycle -- Calculate cycle_end from cycle_start + interval +**Interval logic:** -**Functions (high-level):** +- **Monthly:** 1st of month → last day of month. +- **Quarterly:** 1st of quarter (Jan/Apr/Jul/Oct) → last day of quarter. +- **Half-yearly:** 1st of half (Jan/Jul) → last day of half. +- **Yearly:** Jan 1 → Dec 31. -- `calculate_cycle_start/3` - Given date and interval, find cycle start -- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end -- `next_cycle_start/2` - Given cycle_start and interval, find next -- `is_current_cycle?/2` - Check if cycle contains today -- `is_last_completed_cycle?/2` - Check if cycle just ended +### Status Management — Ash actions on `MembershipFeeCycle` -**Interval Logic:** +Simple state machine unpaid ↔ paid ↔ suspended; all transitions allowed; permissions checked via Ash policies. Actions: `mark_as_paid`, `mark_as_suspended`, `mark_as_unpaid` (error correction). `bulk_mark_as_paid` is low priority / future. -- **Monthly:** Start = 1st of month, End = last day of month -- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter -- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half -- **Yearly:** Start = Jan 1st, End = Dec 31st +### Membership Fee Type Change — Ash change on `Member.membership_fee_type_id` -### Status Management +**Validation:** new type must have the same interval as the old type; different interval is rejected (MVP constraint). -**Component:** Ash actions on `MembershipFeeCycle` +**Side effects on allowed change:** keep all existing cycles; find future unpaid cycles, delete them, regenerate with the new `membership_fee_type_id` and amount. -**Status Transitions:** +**Implementation pattern:** -- Simple state machine: unpaid ↔ paid ↔ suspended -- No complex validation (all transitions allowed) -- Permissions checked via Ash policies +- Ash change module validates; `after_action` hook triggers regeneration synchronously. +- **Regeneration runs in the same transaction as the member update** to ensure atomicity. CycleGenerator uses advisory locks and transactions internally to prevent races. -**Actions Required:** +**Validation behavior:** -- `mark_as_paid` - Set status to :paid -- `mark_as_suspended` - Set status to :suspended -- `mark_as_unpaid` - Set status to :unpaid (error correction) - -**Bulk Operations:** - -- `bulk_mark_as_paid` - Mark multiple cycles as paid (efficiency) - - low priority, can be a future issue - -### Membership Fee Type Change Handling - -**Component:** Ash change on `Member.membership_fee_type_id` - -**Validation:** - -- Check if new type has same interval as old type -- If different: Reject change (MVP constraint) -- If same: Allow change - -**Side Effects on Allowed Change:** - -1. Keep all existing cycles unchanged -2. Find future unpaid cycles -3. Delete future unpaid cycles -4. Regenerate cycles with new membership_fee_type_id and amount - -**Implementation Pattern:** - -- Use Ash change module to validate -- Use after_action hook to trigger regeneration synchronously -- Regeneration runs in the same transaction as the member update to ensure atomicity -- CycleGenerator uses advisory locks and transactions internally to prevent race conditions - -**Validation Behavior:** - -- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error -- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists +- **Fail-closed:** if fee types cannot be loaded during validation, the change is rejected with a validation error. +- **Nil prevention:** setting `membership_fee_type_id` to nil is rejected when a current type exists. --- ## Integration Points -### Member Resource Integration +### Member Resource -**Extension Points:** +Extension points: fields via migration; relationships (belongs_to, has_many); calculations (current_cycle_status, overdue_count); changes (auto-set `membership_fee_start_date`, validate interval). Backward compatible — new fields nullable/defaulted; existing members get the default fee type from settings. -1. Add fields via migration -2. Add relationships (belongs_to, has_many) -3. Add calculations (current_cycle_status, overdue_count) -4. Add changes (auto-set membership_fee_start_date, validate interval) +### Settings System -**Backward Compatibility:** +Store two global settings, admin UI to modify, default values if unset, validation (default fee type must exist). -- New fields nullable or with defaults -- Existing members get default membership fee type from settings -- No breaking changes to existing member functionality +### Permission System — Implemented -### Settings System Integration +See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full matrix and policy patterns. -**Requirements:** +**PermissionSets (`lib/mv/authorization/permission_sets.ex`):** -- Store two global settings -- Provide UI for admin to modify -- Default values if not set -- Validation (e.g., default membership fee type must exist) +- **MembershipFeeType:** all sets read (:all); only admin has create/update/destroy (:all). +- **MembershipFeeCycle:** all read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all). +- **Manual "Regenerate Cycles" (UI + server):** the "Regenerate Cycles" button in the member detail view is shown to users with MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler **also enforces `can?(:create, MembershipFeeCycle)` server-side** before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with the system actor. -**Access Pattern:** +**Resource policies:** -- Read settings during cycle generation -- Read settings during member creation -- Write settings only via admin UI - -### Permission System Integration - -**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns. - -**PermissionSets (lib/mv/authorization/permission_sets.ex):** - -- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all). -- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all). -- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor. - -**Resource Policies:** - -- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy. -- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid. +- `MembershipFeeType` (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy. +- `MembershipFeeCycle` (`lib/membership_fees/membership_fee_cycle.ex`): same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid. ### LiveView Integration -**New LiveViews Required:** +**New:** MembershipFeeType index/form (admin); MembershipFeeCycle table component in the member detail view — implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` (displays all cycles with status management, amount editing, and manual regeneration for normal_user and admin); Settings form section (admin); member-list status column. -1. MembershipFeeType index/form (admin) -2. MembershipFeeCycle table component (member detail view) - - Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` - - Displays all cycles in a table with status management - - Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin) -3. Settings form section (admin) -4. Member list column (membership fee status) - -**Existing LiveViews to Extend:** - -- Member detail view: Add membership fees section -- Member list view: Add status column -- Settings page: Add membership fees section - -**Authorization Helpers:** - -- Use existing `can?/3` helper for UI conditionals -- Check permissions before showing actions +**Extended:** member detail view (membership fees section), member list view (status column), settings page (membership fees section). Use the existing `can?/3` helper for UI conditionals. --- -## Acceptance Criteria +## Performance Notes -### MembershipFeeType Resource +**Indexes:** `membership_fee_cycles` on `member_id`, `membership_fee_type_id`, `status`, `cycle_start`, composite unique `(member_id, cycle_start)`; `members(membership_fee_type_id)`. -**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description -**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt) -**AC-MFT-3:** Admin can update name, amount, description (but not interval) -**AC-MFT-4:** Cannot delete membership fee type if assigned to members -**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it -**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly +**Query:** preload fee type with cycles to avoid N+1; `cycle_end` and `current_cycle_status` are Ash calculations (lazy, not stored); paginate cycle lists > 50. -### MembershipFeeCycle Resource - -**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id -**AC-MFC-2:** cycle_end is calculated, not stored -**AC-MFC-3:** Status defaults to :unpaid -**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint) -**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount -**AC-MFC-6:** Cycles cascade delete when member deleted -**AC-MFC-7:** Admin/Treasurer can change status -**AC-MFC-8:** Member can read own cycles - -### Member Extensions - -**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default) -**AC-M-2:** Member has membership_fee_start_date field (nullable) -**AC-M-3:** New members get default membership fee type from global setting -**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting -**AC-M-5:** Admin can manually override membership_fee_start_date -**AC-M-6:** Cannot change to membership fee type with different interval (MVP) - -### Cycle Generation - -**AC-CG-1:** Cycles generated when member gets membership fee type -**AC-CG-2:** Cycles generated when member created (via change hook) -**AC-CG-3:** Scheduled job generates missing cycles daily -**AC-CG-4:** Generation respects membership_fee_start_date -**AC-CG-5:** Generation stops at exit_date if member exited -**AC-CG-6:** Generation is idempotent (skips existing cycles) -**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year) -**AC-CG-8:** Amount comes from membership_fee_type at generation time - -### Calendar Logic - -**AC-CL-1:** Monthly cycles: 1st to last day of month -**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter -**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half -**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31 -**AC-CL-5:** cycle_end calculated correctly for all interval types -**AC-CL-6:** Current cycle determined correctly based on today's date -**AC-CL-7:** Last completed cycle determined correctly - -### Membership Fee Type Change - -**AC-TC-1:** Can change to type with same interval -**AC-TC-2:** Cannot change to type with different interval (error message) -**AC-TC-3:** On allowed change: future unpaid cycles regenerated -**AC-TC-4:** On allowed change: paid/suspended cycles unchanged -**AC-TC-5:** On allowed change: amount updated to new type's amount -**AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update - -### Settings - -**AC-S-1:** Global setting: include_joining_cycle (boolean, default true) -**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required) -**AC-S-3:** Admin can modify settings via UI -**AC-S-4:** Settings validated (e.g., default membership fee type must exist) -**AC-S-5:** Settings applied to new members immediately - -### UI - Member List - -**AC-UI-ML-1:** New column shows membership fee status -**AC-UI-ML-2:** Default: Shows last completed cycle status -**AC-UI-ML-3:** Optional: Toggle to show current cycle status -**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) -**AC-UI-ML-5:** Filter: Unpaid in last cycle -**AC-UI-ML-6:** Filter: Unpaid in current cycle - -### UI - Member Detail - -**AC-UI-MD-1:** Membership fees section shows all cycles -**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions -**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio) -**AC-UI-MD-4:** "Mark selected as paid" button -**AC-UI-MD-5:** Dropdown to change membership fee type (same interval only) -**AC-UI-MD-6:** Warning if different interval selected -**AC-UI-MD-7:** Only show actions if user has permission - -### UI - Membership Fee Types Admin - -**AC-UI-CTA-1:** List all membership fee types -**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count -**AC-UI-CTA-3:** Create new membership fee type form -**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable -**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) -**AC-UI-CTA-6:** Warning on amount change (explain impact) -**AC-UI-CTA-7:** Cannot delete if members assigned -**AC-UI-CTA-8:** Only admin can access - -### UI - Settings Admin - -**AC-UI-SA-1:** Membership fees section in settings -**AC-UI-SA-2:** Dropdown to select default membership fee type -**AC-UI-SA-3:** Checkbox: Include joining cycle -**AC-UI-SA-4:** Explanatory text with examples -**AC-UI-SA-5:** Save button with validation - ---- - -## Testing Strategy - -### Unit Testing - -**Cycle Generator Tests:** - -- Correct cycle_start calculation for all interval types -- Correct cycle count from start to end date -- Respects membership_fee_start_date boundary -- Respects exit_date boundary -- Skips existing cycles (idempotent) -- Does not fill gaps when cycles were deleted -- Handles edge dates (year boundaries, leap years) - -**Calendar Cycles Tests:** - -- Cycle boundaries correct for all intervals -- cycle_end calculation correct -- Current cycle detection -- Last completed cycle detection -- Next cycle calculation - -**Validation Tests:** - -- Interval immutability enforced -- Same interval validation on type change -- Status transitions allowed -- Uniqueness constraints enforced - -### Integration Testing - -**Cycle Generation Flow:** - -- Member creation triggers generation -- Type assignment triggers generation -- Type change regenerates future cycles -- Scheduled job generates missing cycles -- Left member stops generation - -**Status Management Flow:** - -- Mark single cycle as paid -- Bulk mark multiple cycles (low prio) -- Status transitions work -- Permissions enforced - -**Membership Fee Type Management:** - -- Create type -- Update amount (regeneration triggered) -- Cannot update interval -- Cannot delete if in use - -### LiveView Testing - -**Member List:** - -- Status column displays correctly -- Toggle between last/current works -- Filters work correctly -- Color coding applied - -**Member Detail:** - -- Cycles table displays all cycles -- Checkboxes work -- Bulk marking works (low prio) -- Membership fee type change validation works -- Actions only shown with permission - -**Admin UI:** - -- Type CRUD works -- Settings save correctly -- Validations display errors -- Only authorized users can access - -### Edge Case Testing - -**Interval Change Attempt:** - -- Error message displayed -- No data modified -- User can cancel/choose different type - -**Exit with Unpaid:** - -- Warning shown -- Option to suspend offered -- Exit completes correctly - -**Amount Change:** - -- Warning displayed -- Only future unpaid regenerated -- Historical cycles unchanged - -**Date Boundaries:** - -- Today = cycle start handled -- Today = cycle end handled -- Leap year handled - -### Performance Testing - -**Cycle Generation:** - -- Generate 10 years of monthly cycles: < 100ms -- Generate for 1000 members: < 5 seconds -- Idempotent check efficient (no full scan) - -**Member List Query:** - -- With status column: < 200ms for 1000 members -- Filters applied efficiently -- No N+1 queries - ---- - -## Security Considerations - -### Authorization - -**Permissions Required:** - -- Membership fee type management: Admin only -- Membership fee cycle status changes: Admin + Treasurer -- View all cycles: Admin + Treasurer + Board -- View own cycles: All authenticated users - -**Policy Enforcement:** - -- All actions protected by Ash policies -- UI shows/hides based on permissions -- Backend validates permissions (never trust UI alone) - -### Data Integrity - -**Validation Layers:** - -1. Database constraints (NOT NULL, UNIQUE, CHECK) -2. Ash validations (business rules) -3. UI validations (user experience) - -**Immutability Protection:** - -- Interval change prevented at multiple layers -- Cycle amounts immutable (audit trail) -- Settings changes logged (future) - -### Audit Trail - -**Tracked Information:** - -- Cycle status changes (who, when) - future enhancement -- Membership fee type amount changes (implicit via cycle amounts) - ---- - -## Performance Considerations - -### Database Indexes - -**Required Indexes:** - -- `membership_fee_cycles(member_id)` - For member cycle lookups -- `membership_fee_cycles(membership_fee_type_id)` - For type queries -- `membership_fee_cycles(status)` - For unpaid filters -- `membership_fee_cycles(cycle_start)` - For date range queries -- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index -- `members(membership_fee_type_id)` - For type membership count - -### Query Optimization - -**Preloading:** - -- Load membership_fee_type with cycles (avoid N+1) -- Load cycles when displaying member detail -- Use Ash's load for efficient preloading - -**Calculated Fields:** - -- cycle_end calculated on-demand (not stored) -- current_cycle_status calculated when needed -- Use Ash calculations for lazy evaluation - -**Pagination:** - -- Cycle list paginated if > 50 cycles -- Member list already paginated - -### Caching Strategy - -**No caching needed in MVP:** - -- Membership fee types rarely change -- Cycle queries are fast -- Settings read infrequently - -**Future caching if needed:** - -- Cache settings in application memory -- Cache membership fee types list -- Invalidate on change - -### Scheduled Job Performance - -**Cycle Generation Job:** - -- Run daily or weekly (not hourly) -- Batch members (process 100 at a time) -- Skip members with no changes -- Log failures for retry +**No caching in MVP** (fee types rarely change, queries fast). Scheduled generation job: run daily/weekly, batch members, skip unchanged, log failures for retry. --- ## Future Enhancements -### Phase 2: Interval Change Support - -**Architecture Changes:** - -- Add logic to handle cycle overlaps -- Calculate prorata amounts if needed -- More complex validation -- Migration path for existing cycles - -### Phase 3: Payment Details - -**Architecture Changes:** - -- Add PaymentTransaction resource -- Link transactions to cycles -- Support multiple payments per cycle -- Reconciliation logic - -### Phase 4: vereinfacht.digital Integration - -**Architecture Changes:** - -- External API client module -- Webhook handling for transactions -- Automatic matching logic -- Manual review interface - ---- - -**End of Architecture Document** +- **Phase 2 — Interval change support:** cycle-overlap logic, prorata, more validation, migration path for existing cycles. +- **Phase 3 — Payment details:** `PaymentTransaction` resource linked to cycles, multiple payments per cycle, reconciliation. +- **Phase 4 — vereinfacht.digital integration:** external API client, webhook handling, automatic matching, manual review. diff --git a/docs/membership-fee-overview.md b/docs/membership-fee-overview.md index 8eb48b0..b00178d 100644 --- a/docs/membership-fee-overview.md +++ b/docs/membership-fee-overview.md @@ -1,50 +1,20 @@ # Membership Fees - Overview -**Project:** Mila - Membership Management System -**Feature:** Membership Fee Management -**Version:** 1.0 -**Last Updated:** 2026-01-13 -**Status:** ✅ Implemented +**Feature:** Membership Fee Management — **Status:** Implemented ---- - -## Purpose - -This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. - -**For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-implementation-plan.md) (created after concept iterations) - ---- - -## Table of Contents - -1. [Core Principle](#core-principle) -2. [Terminology](#terminology) -3. [Data Model](#data-model) -4. [Business Logic](#business-logic) -5. [UI/UX Design](#uiux-design) -6. [Edge Cases](#edge-cases) -7. [Technical Integration](#technical-integration) -8. [Implementation Scope](#implementation-scope) +Coarse, business-oriented entry point for the Membership Fees system: terminology, worked examples, and UI/UX. For architecture (data model, FK behaviors, module map, generation algorithm, policies) see [membership-fee-architecture.md](./membership-fee-architecture.md). --- ## Core Principle -**Maximum Simplicity:** - -- Minimal complexity -- Clear data model without redundancies -- Intuitive operation -- Calendar cycle-based (Month/Quarter/Half-Year/Year) +Maximum simplicity: minimal complexity, clear data model without redundancies, intuitive operation, calendar-cycle-based (Month / Quarter / Half-Year / Year). --- -## Terminology +## Terminology (German ↔ English) -### German ↔ English - -**Core Entities:** +**Core entities:** - Beitragsart ↔ Membership Fee Type - Beitragszyklus ↔ Membership Fee Cycle @@ -56,14 +26,14 @@ This document provides a comprehensive overview of the Membership Fees system. I - unbezahlt ↔ unpaid - ausgesetzt ↔ suspended / waived -**Intervals (Frequenz / Payment Frequency):** +**Intervals (Frequenz / payment frequency):** - monatlich ↔ monthly - quartalsweise ↔ quarterly - halbjährlich ↔ half-yearly / semi-annually - jährlich ↔ yearly / annually -**UI Elements:** +**UI elements:** - "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024) - "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024) @@ -72,112 +42,39 @@ This document provides a comprehensive overview of the Membership Fees system. I --- -## Data Model +## Data Model (summary) -### Membership Fee Type (MembershipFeeType) +Three entities — full schema, FK on-delete behaviors, and design rationale are in [membership-fee-architecture.md](./membership-fee-architecture.md). -``` -- id (UUID) -- name (String) - e.g., "Regular", "Reduced", "Student" -- amount (Decimal) - Membership fee amount in Euro -- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly -- description (Text, optional) -``` +- **MembershipFeeType:** name, amount (€), `interval` (:monthly/:quarterly/:half_yearly/:yearly), optional description. `interval` is **IMMUTABLE** after creation; admin can change only name/amount/description; on amount change, future unpaid cycles regenerate with the new amount. +- **MembershipFeeCycle:** member_id, membership_fee_type_id, `cycle_start` (calendar start: 01.01., 01.04., 01.07., 01.10., …), status (:unpaid default / :paid / :suspended), `amount` (captured at generation time → history when type changes), optional notes. NO `cycle_end` (derived from `cycle_start` + interval), NO `interval_type` (read from the fee type). +- **Member extensions:** `membership_fee_type_id` (FK, nullable — default applied from settings at the app level), `membership_fee_start_date` (Date, nullable), plus the existing `exit_date`. -**Important:** +**Calendar cycle logic:** Monthly 01.01.–31.01., etc. · Quarterly 01.01.–31.03., 01.04.–30.06., 01.07.–30.09., 01.10.–31.12. · Half-yearly 01.01.–30.06., 01.07.–31.12. · Yearly 01.01.–31.12. -- `interval` is **IMMUTABLE** after creation! -- Admin can only change `name`, `amount`, `description` -- On change: Future unpaid cycles regenerated with new amount +### `membership_fee_start_date` derivation -### Membership Fee Cycle (MembershipFeeCycle) +Auto-set from global setting `include_joining_cycle`: -``` -- id (UUID) -- member_id (FK → members.id) -- membership_fee_type_id (FK → membership_fee_types.id) -- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.) -- status (Enum) - :unpaid (default), :paid, :suspended -- amount (Decimal) - Membership fee amount at generation time (history when type changes) -- notes (Text, optional) - Admin notes -``` +- `include_joining_cycle = true` → first day of the joining month/quarter/year (member pays from the joining cycle). +- `include_joining_cycle = false` → first day of the NEXT cycle after joining. -**Important:** +Can be manually overridden by admin. There is intentionally **no** `include_joining_cycle` field on Member — `membership_fee_start_date` makes it unnecessary. -- **NO** `cycle_end` - calculated from `cycle_start` + `interval` -- **NO** `interval_type` - read from `membership_fee_type.interval` -- Avoids redundancy and inconsistencies! +### Global settings -**Calendar Cycle Logic:** - -- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. -- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12. -- Half-yearly: 01.01. - 30.06., 01.07. - 31.12. -- Yearly: 01.01. - 31.12. - -### Member (Extensions) - -``` -- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings) -- membership_fee_start_date (Date, nullable) - When to start generating membership fees -- exit_date (Date, nullable) - Exit date (existing) -``` - -**Logic for membership_fee_start_date:** - -- Auto-set based on global setting `include_joining_cycle` -- If `include_joining_cycle = true`: First day of joining month/quarter/year -- If `include_joining_cycle = false`: First day of NEXT cycle after joining -- Can be manually overridden by admin - -**NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`! - -### Global Settings - -``` -key: "membership_fees.include_joining_cycle" -value: Boolean (Default: true) - -key: "membership_fees.default_membership_fee_type_id" -value: UUID (Required) - Default membership fee type for new members -``` - -**Meaning include_joining_cycle:** - -- `true`: Joining cycle is included (member pays from joining cycle) -- `false`: Only from next full cycle after joining - -**Meaning of default membership fee type setting:** - -- Every new member automatically gets this membership fee type -- Must be configured in admin settings -- Prevents: Members without membership fee type +- `membership_fees.include_joining_cycle` — Boolean (default `true`): whether the joining cycle is billed. +- `membership_fees.default_membership_fee_type_id` — UUID (required): fee type auto-assigned to every new member; must be configured in admin settings (prevents members without a fee type). --- ## Business Logic -### Cycle Generation +### Cycle generation -**Triggers:** +**Triggers:** fee type assigned (incl. at member creation), new cycle begins (cron daily/weekly), admin manual regeneration. Uses PostgreSQL advisory locks per member. -- Member gets membership fee type assigned (also during member creation) -- New cycle begins (Cron job daily/weekly) -- Admin requests manual regeneration - -**Algorithm:** - -Use PostgreSQL advisory locks per member to prevent race conditions - -1. Get `member.membership_fee_start_date` and member's membership fee type -2. Determine generation start point: - - If NO cycles exist: Start from `membership_fee_start_date` - - If cycles exist: Start from the cycle AFTER the last existing one -3. Generate cycles until today (or `exit_date` if present): - - Use the interval to generate the cycles - - **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated. - The generator always continues from the cycle AFTER the last existing cycle. -4. Set `amount` to current membership fee type's amount +**Algorithm:** start from `membership_fee_start_date` if no cycles exist, else from the cycle AFTER the last existing one; generate to today (or `exit_date`); set each cycle's `amount` from the current fee type. **Deleted cycles (gaps) are NOT recreated** — generation always continues after the last existing cycle. (Full algorithm in architecture doc.) **Example (Yearly):** @@ -207,93 +104,31 @@ Generated cycles: - ... ``` -### Status Transitions +### Status transitions -``` -unpaid → paid -unpaid → suspended -paid → unpaid -suspended → paid -suspended → unpaid -``` +unpaid → paid · unpaid → suspended · paid → unpaid · suspended → paid · suspended → unpaid. Admin + Treasurer (Kassenwart) can change status, via the existing permission system. -**Permissions:** +### Membership fee type change -- Admin + Treasurer (Kassenwart) can change status -- Uses existing permission system +MVP allows changing only to a fee type with the **same interval** (e.g. "Regular (yearly)" → "Reduced (yearly)" ✓; → "Reduced (monthly)" ✗). On change: set `member.membership_fee_type_id`; future **unpaid** cycles deleted and regenerated with the new amount; paid/suspended cycles unchanged (historical amount). Future: enable interval switching (overlap handling, extra validation). -### Membership Fee Type Change +### Member exit -**MVP - Same Cycle Only:** - -- Member can only choose membership fee type with **same cycle** -- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓ -- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗ - -**Logic on Change:** - -1. Check: New membership fee type has same interval -2. If yes: Set `member.membership_fee_type_id` -3. Future **unpaid** cycles: Delete and regenerate with new amount -4. Paid/suspended cycles: Remain unchanged (historical amount) - -**Future - Different Intervals:** - -- Enable interval switching (e.g., yearly → monthly) -- More complex logic for cycle overlaps -- Needs additional validation - -### Member Exit - -**Logic:** - -- Cycles only generated until `member.exit_date` -- Existing cycles remain visible -- Unpaid exit cycle can be marked as "suspended" - -**Example:** - -``` -Exit: 15.08.2024 -Yearly cycle: 01.01.2024 - 31.12.2024 - -→ Cycle 2024 is shown (Status: unpaid) -→ Admin can set to "suspended" -→ No cycles for 2025+ generated -``` +Cycles generated only up to `member.exit_date`; existing cycles remain visible; an unpaid exit cycle can be marked "suspended". E.g. exit 15.08.2024 with a yearly cycle 01.01.–31.12.2024 → 2024 cycle shown (unpaid, admin may suspend); no 2025+ cycles generated. --- ## UI/UX Design -### Member List View +### Member List View — column "Membership Fee Status" -**New Column: "Membership Fee Status"** +- **Default (last completed cycle):** in 2024, shows the 2023 status. Color: green = paid ✓, red = unpaid ✗, gray = suspended ⊘. +- **Optional toggle:** "Show current cycle" (2024). +- **Filters:** "Unpaid membership fees in last cycle", "Unpaid membership fees in current cycle". -**Default Display (Last Cycle):** +### Member Detail View — section "Membership Fees" -- Shows status of **last completed** cycle -- Example in 2024: Shows membership fee for 2023 -- Color coding: - - Green: paid ✓ - - Red: unpaid ✗ - - Gray: suspended ⊘ - -**Optional: Show Current Cycle** - -- Toggle: "Show current cycle" (2024) -- Admin decides what to display - -**Filters:** - -- "Unpaid membership fees in last cycle" -- "Unpaid membership fees in current cycle" - -### Member Detail View - -**Section: "Membership Fees"** - -**Membership Fee Type Assignment:** +**Fee type assignment:** ``` ┌─────────────────────────────────────┐ @@ -303,7 +138,7 @@ Yearly cycle: 01.01.2024 - 31.12.2024 └─────────────────────────────────────┘ ``` -**Cycle Table:** +**Cycle table:** ``` ┌───────────────┬──────────┬────────┬──────────┬─────────┐ @@ -322,11 +157,7 @@ Yearly cycle: 01.01.2024 - 31.12.2024 Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended ``` -**Quick Marking:** - -- Checkbox in each row for fast marking -- Button: "Mark selected as paid/unpaid/suspended" -- Bulk action for multiple cycles +**Quick marking:** checkbox per row; "Mark selected as paid/unpaid/suspended" button; bulk action for multiple cycles. ### Admin: Membership Fee Types Management @@ -342,18 +173,13 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended └────────────┴──────────┴──────────┴────────────┴─────────┘ ``` -**Edit:** +**Edit:** Name ✓, Amount ✓, Description ✓ editable; Interval ✗ **NOT** editable (grayed out). -- Name: ✓ editable -- Amount: ✓ editable -- Description: ✓ editable -- Interval: ✗ **NOT** editable (grayed out) - -**Warning on Amount Change:** +**Warning on amount change:** ``` ⚠ Change amount to 65 €? - + Impact: - 45 members affected - Future unpaid cycles will be generated with 65 € @@ -362,9 +188,7 @@ Impact: [Cancel] [Confirm] ``` -### Admin: Settings - -**Membership Fee Configuration:** +### Admin: Settings — Membership Fee Configuration ``` Default Membership Fee Type: [Dropdown: Membership Fee Types] @@ -397,135 +221,58 @@ Joining: 15.03.2023 ## Edge Cases -### 1. Membership Fee Type Change with Different Interval +1. **Type change with different interval:** MVP blocks it. UI message: -**MVP:** Blocked (only same interval allowed) + ``` + Error: Interval change not possible -**UI:** + Current membership fee type: "Regular (Yearly)" + Selected membership fee type: "Student (Monthly)" -``` -Error: Interval change not possible + Changing the interval is currently not possible. + Please select a membership fee type with interval "Yearly". -Current membership fee type: "Regular (Yearly)" -Selected membership fee type: "Student (Monthly)" + [OK] + ``` -Changing the interval is currently not possible. -Please select a membership fee type with interval "Yearly". + Future: allow interval switching with overlap calculation and no duplicate cycles. -[OK] -``` +2. **Exit with unpaid fees (low prio):** on exit, offer to mark unpaid cycles "suspended". -**Future:** + ``` + ⚠ Unpaid membership fees present -- Allow interval switching -- Calculate overlaps -- Generate new cycles without duplicates + This member has 1 unpaid cycle(s): + - 2024: 60 € (unpaid) -### 2. Exit with Unpaid Membership Fees + Do you want to continue? -**Scenario:** + [ ] Mark membership fee as "suspended" + [Cancel] [Confirm Exit] + ``` -``` -Member exits: 15.08.2024 -Yearly cycle 2024: unpaid -``` +3. **Multiple unpaid cycles:** all shown; select several and bulk-mark. -**UI Notice on Exit: (Low Prio)** + ``` + ┌───────────────┬──────────┬────────┬──────────┬─────────┐ + │ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │ + │ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │ + │ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │ + └───────────────┴──────────┴────────┴──────────┴─────────┘ -``` -⚠ Unpaid membership fees present + [Mark selected as paid/unpaid/suspended] (2 selected) + ``` -This member has 1 unpaid cycle(s): -- 2024: 60 € (unpaid) +4. **Amount changes:** 2023 Regular = 50 €, 2024 = 60 € → 2023 cycle keeps 50 € (history), 2024 generated with 60 €; each cycle shows its historical amount. -Do you want to continue? - -[ ] Mark membership fee as "suspended" -[Cancel] [Confirm Exit] -``` - -### 3. Multiple Unpaid Cycles - -**Scenario:** Member hasn't paid for 2 years - -**Display:** - -``` -┌───────────────┬──────────┬────────┬──────────┬─────────┐ -│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │ -│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │ -│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │ -└───────────────┴──────────┴────────┴──────────┴─────────┘ - -[Mark selected as paid/unpaid/suspended] (2 selected) -``` - -### 4. Amount Changes - -**Scenario:** - -``` -2023: Regular = 50 € -2024: Regular = 60 € (increase) -``` - -**Result:** - -- Cycle 2023: Saved with 50 € (history) -- Cycle 2024: Generated with 60 € (current) -- Both cycles show correct historical amount - -### 5. Date Boundaries - -**Problem:** What if today = 01.01.2025? - -**Solution:** - -- Current cycle (2025) is generated -- Status: unpaid (open) -- Shown in overview +5. **Date boundaries:** today = 01.01.2025 → current 2025 cycle generated, status unpaid, shown in overview. --- ## Implementation Scope -### MVP (Phase 1) +**MVP (Phase 1) — included:** fee types CRUD; automatic cycle generation; status management (paid/unpaid/suspended); member overview with status; per-member cycle view; quick checkbox marking; bulk actions; amount history; same-interval type change; default fee type; joining-cycle configuration. -**Included:** +**NOT included:** interval change; payment details (date, method); automatic vereinfacht.digital integration; prorata; reports/statistics; reminders/dunning (manual via filters). -- ✓ Membership fee types (CRUD) -- ✓ Automatic cycle generation -- ✓ Status management (paid/unpaid/suspended) -- ✓ Member overview with membership fee status -- ✓ Cycle view per member -- ✓ Quick checkbox marking -- ✓ Bulk actions -- ✓ Amount history -- ✓ Same-interval type change -- ✓ Default membership fee type -- ✓ Joining cycle configuration - -**NOT Included:** - -- ✗ Interval change (only same interval) -- ✗ Payment details (date, method) -- ✗ Automatic integration (vereinfacht.digital) -- ✗ Prorata calculation -- ✗ Reports/statistics -- ✗ Reminders/dunning (manual via filters) - -### Future Enhancements - -**Phase 2:** - -- Payment details (date, amount, method) -- Interval change for future unpaid cycles -- Manual vereinfacht.digital links per member -- Extended filter options - -**Phase 3:** - -- Automated vereinfacht.digital integration -- Automatic payment matching -- SEPA integration -- Advanced reports +**Future:** Phase 2 — payment details, interval change for future unpaid cycles, manual vereinfacht.digital links per member, extended filters. Phase 3 — automated vereinfacht.digital integration, automatic payment matching, SEPA, advanced reports. diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 8e6c615..0dbf7c8 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -1,20 +1,16 @@ # Onboarding & Join – High-Level Concept -**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 1–4) implemented.** -**Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths. +**Status:** Prio 1 (Subtasks 1–4) and Step 2 (Vorstand approval, Subtask 5) implemented. The Invite-Link / OIDC-JIT join entry paths (§4) are designed here but **not yet implemented**. **Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage. --- ## 1. Focus and Goals -- **Focus:** Onboarding and **initial data capture**, not self-service editing of existing members. -- **Entry paths (vision):** - - **Public Join form** (Prio 1) – unauthenticated submission. - - **Invite link** (tokenized) – later. - - **OIDC first-login** (Just-in-Time Provisioning) – later. -- **Admin control:** All entry paths and their behaviour (e.g. which fields, approval required) shall be configurable by admins; MVP can start with sensible defaults. -- **Approval:** A Vorstand (board) approval step is a direct follow-up (Step 2) after the public Join; the data model and flow must support it. +- **Focus:** onboarding and **initial data capture**, not self-service editing of existing members. +- **Entry paths (vision):** public Join form (Prio 1, unauthenticated submission); invite link (tokenized, later); OIDC first-login / Just-in-Time Provisioning (later). +- **Admin control:** all entry paths and their behaviour (which fields, approval required) shall be admin-configurable; MVP may start with sensible defaults. +- **Approval:** a Vorstand (board) approval step is the direct follow-up (Step 2) after the public Join; the data model and flow support it. --- @@ -22,168 +18,137 @@ ### 2.1 Intent -- **Public** page (e.g. `/join`): no login; anyone can open and submit. -- Result is **not** a User or Member. Result is an **onboarding / join request**: the JoinRequest record is **created in the database on form submit** in status `pending_confirmation`, then **updated to** `submitted` after the user clicks the confirmation link. -- This keeps: - - **Public intake** (abuse-prone) separate from **identity and account creation** (after approval / invite / OIDC). - - Existing policies (e.g. User–Member linking, admin-only link) untouched until a defined "promotion" flow (e.g. after approval) creates User/Member. -- **Elixir/Phoenix/Ash standard:** Data is persisted in the database from the start (one Ash resource, status-driven flow). No ETS or stateless token for pre-confirmation storage; confirm flow only updates the existing record. +- **Public** page `/join`: no login; anyone can open and submit. +- The result is **not** a User or Member but a **JoinRequest** record, created in the DB on form submit in status `pending_confirmation`, then updated to `submitted` after the user clicks the confirmation link. +- This keeps public intake (abuse-prone) separate from identity/account creation, and leaves existing policies (User–Member linking, admin-only link) untouched until a defined promotion flow (after approval) creates User/Member. +- **Standard:** data is persisted in the DB from the start (one Ash resource, status-driven). No ETS or stateless token for pre-confirmation storage; the confirm flow only updates the existing record. -### 2.2 User Flow (Prio 1) +### 2.2 User Flow 1. Unauthenticated user opens `/join`. -2. Short explanation + form (what happens next: "We will review … you will hear from us"). -3. **Submit** → A **JoinRequest is created** in the database with status `pending_confirmation`; confirmation email is sent; user sees: "We have saved your details. To complete your request, please click the link we sent to your email." -4. **User clicks confirmation link** → The existing JoinRequest is **updated** to status `submitted` (`submitted_at` set, confirmation token invalidated); user sees: "Thank you, we have received your request." +2. Short explanation + form ("We will review … you will hear from us"). +3. **Submit** → JoinRequest created with status `pending_confirmation`; confirmation email sent; user sees "We have saved your details. To complete your request, please click the link we sent to your email." +4. **User clicks confirmation link** → existing JoinRequest updated to `submitted` (`submitted_at` set, confirmation token invalidated); user sees "Thank you, we have received your request." -**Rationale (double opt-in with DB-first):** Email confirmation remains best practice (we only treat the request as "submitted" after the link is clicked). The record exists in the DB from submit time so we use standard Phoenix/Ash persistence, multi-node safety, and a simple status transition (`pending_confirmation` → `submitted`) on confirm. This aligns with patterns like AshAuthentication (resource exists before confirm; confirm updates state). - -**Out of scope for Prio 1:** Approval UI, account creation, OIDC, invite links. +**Rationale (double opt-in, DB-first):** email confirmation stays best practice (treated as "submitted" only after the click); the record exists in the DB from submit time, so we get standard Phoenix/Ash persistence, multi-node safety, and a simple `pending_confirmation → submitted` transition. Aligns with AshAuthentication (resource exists before confirm; confirm updates state). ### 2.3 Data Flow -- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys. -- **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email. -- **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent). -- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval). +- **Input:** only data explicitly allowed for the public form; field set is admin-configured (§2.6). No internal/sensitive fields. **Server-side allowlist:** accepted fields are enforced both in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`**, so even direct API / `submit_join_request` calls persist only allowlisted `form_data` keys. +- **On submit:** create a JoinRequest (status `pending_confirmation`), store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data, then send the confirmation email. +- **On confirm link click:** find by token hash, set status `submitted`, set `submitted_at`, clear/invalidate token fields. If already `submitted`, return success without changing it (idempotent). +- **No Member/User creation** in Prio 1; promotion happens later (after approval). #### 2.3.1 Pre-Confirmation Store (Decided) -**Decision:** Store in the **database** only. Use the **same** JoinRequest resource and table from the start. +**Decision:** store in the **database** only, using the **same** JoinRequest resource and table throughout. On submit, create one row (`pending_confirmation`, token hash, expiry); on confirm, update that row to `submitted` — no second table, no ETS, no stateless token. -- On submit: **create** one JoinRequest row with status `pending_confirmation`, confirmation token **hash**, and expiry. -- On confirm: **update** that row to status `submitted` (no second table, no ETS, no stateless token). -- **Retention and cleanup:** JoinRequests that remain in `pending_confirmation` past the token expiry (e.g. 24 hours) are **hard-deleted** by a scheduled job (e.g. Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed. -- **Rationale:** Elixir/Phoenix/Ash standard is persistence in DB, one resource, status machine. Multi-node safe, restart safe, and cleanup is a standard cron task. +**Retention and cleanup:** JoinRequests still in `pending_confirmation` past the token expiry are **hard-deleted** by a scheduled job (Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed. Multi-node and restart safe; cleanup is a standard cron task. #### 2.3.2 JoinRequest: Data Model and Schema -- **Status:** `pending_confirmation` (initial, after form submit) → `submitted` (after link click) → later `approved` / `rejected`. Include **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. -- **Confirmation:** Store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. Raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash. -- **Payload vs typed columns (recommendation):** - - **Typed columns** for **email** (required, dedicated field for index, search, dedup, audit) and for **first_name** and **last_name** (optional). These align with `Mv.Constants.member_fields()` and with the existing Member resource; they support approval-list display and straightforward promotion to Member without parsing JSON. - - **Remaining form data** (other member fields + custom field values) in a **jsonb** attribute (e.g. `form_data`) plus a **schema_version** (e.g. tied to join-form or member_fields evolution) so future changes do not break existing records. - - **What it depends on:** (1) Whether the join form field set is fixed or often extended – if fixed, more typed columns are feasible; if very dynamic, keeping the rest in jsonb avoids migrations. (2) Whether the approval UI or reporting needs to filter/sort by other fields (e.g. city) – if yes, consider adding those as typed columns later. For MVP, email + first_name + last_name typed and rest in jsonb is a good balance with the current codebase (Member has typed attributes; export/import use allowlists of field names). -- **Logger hygiene:** Do not log the full payload/form_data; follow CODE_GUIDELINES on log sanitization. -- **Idempotency:** Confirm action finds the JoinRequest by token hash; if status is already `submitted`, return success without updating. Optionally enforce **unique_index on confirmation_token_hash** so the same token cannot apply to more than one record. -- **Abuse metadata:** If stored (e.g. IP hash), classify as **security telemetry** or **personally identifiable** (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added. +- **Status:** `pending_confirmation` (initial) → `submitted` (after link click) → later `approved` / `rejected`. Audit: **approved_at**, **rejected_at**, **reviewed_by_user_id**. +- **Confirmation:** store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. The raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash. +- **Payload vs typed columns:** **typed columns** for **email** (required — dedicated field for index, search, dedup, audit) and **first_name** / **last_name** (optional); these align with `Mv.Constants.member_fields()` and the Member resource, supporting approval-list display and straightforward promotion without parsing JSON. **Remaining form data** (other member fields + custom field values) goes in a **jsonb** attribute (`form_data`) plus a **schema_version** so future changes don't break existing records. + - *Depends on:* (1) whether the join-form field set is fixed (more typed columns feasible) or dynamic (keep rest in jsonb to avoid migrations); (2) whether approval UI/reporting needs to filter/sort by other fields (e.g. city) — if so, add typed columns later. For MVP, email + first_name + last_name typed and the rest in jsonb balances well with the current codebase. +- **Logger hygiene:** do not log the full payload/`form_data`; follow CODE_GUIDELINES on log sanitization. +- **Idempotency:** confirm finds the JoinRequest by token hash; if already `submitted`, return success without updating. Optionally enforce a **unique_index on confirmation_token_hash**. +- **Abuse metadata:** if stored (e.g. IP hash), classify as security telemetry or PII (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added. ### 2.4 Security - **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200). - - **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plug’s **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone. - - **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm. -- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation. -- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention. -- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity. -- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path. -- **No system-actor fallback:** Join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for "missing actor". Use explicit unauthenticated context; see CODE_GUIDELINES §5.0. + - **Explicit public path for `/join`:** add **`/join`** (and if needed `/join/*`) to the page-permission plug's **`public_path?/1`**; do not rely on the confirm path alone. + - **Confirmation route:** use **`/confirm_join/:token`** so the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it — no extra plug change for confirm. +- **Abuse:** **honeypot** + **rate limiting** in MVP (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP: prefer **X-Forwarded-For** / **X-Real-IP** behind a reverse proxy (Endpoint `connect_info: [:x_headers]`, `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour. +- **Data:** minimal PII; no sensitive data on the public form; consider DSGVO when extending. Stored abuse signals: only hashed/aggregated, documented. +- **Approval-only:** no automatic User/Member creation from the join form; approval (Step 2) or another trusted path creates identity. +- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (`submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path. +- **No system-actor fallback:** join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for a "missing actor"; use an explicit unauthenticated context. See CODE_GUIDELINES §5.0 and `lib/mv/authorization/checks/actor_is_nil.ex`. ### 2.5 Usability and UX -- **After submit:** Communicate clearly: e.g. "We have saved your details. To complete your request, please click the link we sent to your email." (Exact copy in implementation spec.) -- Clear heading and short copy (e.g. "Become a member / Submit request" and "What happens next"). +- **After submit:** "We have saved your details. To complete your request, please click the link we sent to your email." +- Clear heading + short copy ("Become a member / Submit request", "What happens next"). - Form only as simple as needed (conversion vs. data hunger). -- Success message after confirm: neutral, no promise of an account ("We will get in touch"). -- **Expired confirmation link:** If the user clicks after the token has expired, show a clear message (e.g. "This link has expired") and instruct them to submit the form again. Specify exact copy and behaviour in the implementation spec. -- **Re-send confirmation link:** Out of scope for Prio 1. If not implemented in Prio 1, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the "Please confirm your email" or expired-link page. -- Accessibility and i18n: same standards as rest of the app (labels, errors, Gettext). +- Confirm success message: neutral, no promise of an account ("We will get in touch"). +- **Expired confirmation link:** clear message ("This link has expired") + instruction to submit the form again. Exact copy in the implementation spec. +- **Re-send confirmation link:** out of scope for Prio 1; if not implemented, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the confirm/expired page. +- Accessibility and i18n: same standards as the rest of the app (labels, errors, Gettext). ### 2.6 Admin Configurability: Join Form Settings -- **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data). -- **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies. -- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants. -- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**. -- **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field. -- **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs. +- **Placement:** own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten". +- **Join form enabled:** checkbox (`join_form_enabled`); when set, the public `/join` page is active and the config below applies. +- **Copyable join link:** when enabled, a copyable full URL to `/join` is shown below the checkbox (above the field list), with a short hint for sharing with applicants. +- **Field selection:** from **all existing** member fields (`Mv.Constants.member_fields()`) and **custom fields**, the admin picks which appear on the join form. Stored as a list/set of field identifiers (no separate table); displayed as **badges with X to remove** (like the groups overview), added via dropdown/modal. Detailed UX to be specified in a separate subtask. +- **Technically required fields:** only **email** must always be required. All others can be optional or marked required per admin choice; support a "required" flag per selected field. +- **Other:** which entry paths are enabled, approval workflow (who can approve) — detailed in Step 2 and later specs. --- -## 3. Step 2: Vorstand Approval +## 3. Step 2: Vorstand Approval (implemented) -- **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject. -- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification. -- **Outcome of approval (admin-configurable):** - - **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed. - - **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented. -- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_user’s allowed pages. +- **Goal:** the board can review join requests (e.g. list status "submitted") and approve or reject. +- **Route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail), defined in `MvWeb.Router` → `MvWeb.JoinRequestLive.Index` / `.Show`. Full spec in §3.1. +- **Outcome of approval:** approval creates a **Member only** (no User; an admin can link a User later). The optional "also create a User on approval" variant is **not yet implemented**. +- **Permissions:** approval uses the existing **normal_user** permission set (e.g. role "Kassenwart"). In `Mv.Authorization.PermissionSets`, normal_user has JoinRequest read + update for scope :all, and `/join_requests` and `/join_requests/:id` are in its allowed pages. -### 3.1 Step 2 – Approval (detail) +### 3.1 Step 2 – Approval (detail) — implemented in Subtask 5 -Implementation spec for Subtask 5. +**Route and pages:** -#### Route and pages +- **List `/join_requests`:** filter by status (default/primary view: `submitted`); optional view for "all" or "approved/rejected" for audit. +- **Detail `/join_requests/:id`:** two blocks — (1) **Applicant data**: all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review**: submitted_at, status, and when decided approved_at/rejected_at + reviewed by. Approve / Reject actions when status is `submitted`. -- **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. -- **Detail:** **`/join_requests/:id`** – single join request. **Two blocks:** (1) **Applicant data** – all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** – submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`. +**Backend (`Mv.Membership.JoinRequest`) — actions (authenticated only):** -#### Backend (JoinRequest) +- **`approve`** (update, change `JoinRequest.Changes.ApproveRequest`): allowed only when status is `submitted`. Sets `approved`, `approved_at`, `reviewed_by_user_id` / `reviewed_by_display` (actor). Promotion to Member is driven by the domain function (see below), not the change. +- **`reject`** (update, change `JoinRequest.Changes.RejectRequest`): allowed only when status is `submitted`. Sets `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP. +- **Policies:** `approve` and `reject` are each permitted via **`HasPermission`**; the read policy uses **`HasJoinRequestAccess`** (a SimpleCheck) so list/detail can load data. Not allowed for `actor: nil`. +- **Domain (`Mv.Membership`):** `list_join_requests/1` (filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). -- **New actions (authenticated only):** - - **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below). - - **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP. -- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`. -- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data. +**Promotion: JoinRequest → Member:** -#### Promotion: JoinRequest → Member +- **When:** on successful `approve` only (status was `submitted`). +- **Mapping:** typed fields **email**, **first_name**, **last_name** → Member attributes. **form_data** keys matching `Mv.Constants.member_fields()` (string form) → Member attributes; keys that are custom field IDs (UUID) → **CustomFieldValue** records linked to the new Member. +- **Defaults:** `join_date` = today. `membership_fee_type_id` is not set here; the Member `create_member` action applies the default fee type from settings (see `Mv.Membership.Member.Changes.SetDefaultMembershipFeeType`). +- **Implementation:** the domain function `Mv.Membership.approve_join_request/2` calls the private `promote_to_member/2`, which builds member attributes + custom_field_values and calls Member `create_member` with the reviewer as actor. No User created in MVP. +- **Atomicity:** the approve flow (get → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`**, so if Member creation fails (validation, unique constraint) the JoinRequest status rolls back. +- **Idempotency:** `ApproveRequest` only transitions from `submitted`; a repeated approve on an already-`approved` request is rejected with a status error, so no duplicate Member is created. -- **When:** On successful `approve` only (status was `submitted`). -- **Mapping:** - - JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes. - - **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member. - - **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest). -- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP. -- **Atomicity:** The approve flow (get JoinRequest → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`** so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent. -- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest). +**Permission sets and routing:** -#### Permission sets and routing +- **PermissionSets (`Mv.Authorization.PermissionSets`, normal_user):** JoinRequest **read** :all and **update** :all; pages `/join_requests` and `/join_requests/:id`. +- **Router (`MvWeb.Router`):** live routes `/join_requests` → `JoinRequestLive.Index` and `/join_requests/:id` → `JoinRequestLive.Show`; entries recorded in **page-permission-route-coverage.md**; plug coverage so normal_user is allowed, read_only/own_data denied. -- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list. -- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied. +**UI/UX (approval):** -#### UI/UX (approval) +- **List:** table/card list with columns e.g. submitted_at, first_name, last_name, email, status; primary/default filter status = `submitted`; links to detail. Follow existing list patterns (Members/Groups): header, back link, CoreComponents table. +- **Detail:** all request data (typed + form_data rendered by field); buttons **Approve** (primary), **Reject** (secondary); reject in MVP has no reason field. Same accessibility/i18n standards. -- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table. -- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields. -- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed). - -#### Tests - -- JoinRequest: policy tests – approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only. -- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error. -- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot. -- Optional: LiveView smoke test – list loads, approve/reject from detail updates state. +**Tests:** policy tests (approve/reject allowed for normal_user and admin, forbidden for nil/own_data/read_only); domain (approve creates one Member with correct mapped data; reject only updates status + audit; approve-when-already-approved is no-op or error); page permission (normal_user can GET both routes; read_only/own_data cannot); optional LiveView smoke test. --- -## 4. Future Entry Paths (Out of Scope Here) +## 4. Future Entry Paths (Out of Scope Here, not yet implemented) -- **Invite link (tokenized):** Unique link per invitee; submission or account creation tied to token. -- **OIDC first-login (JIT):** First login via OIDC creates/links User and optionally Member from IdP data. -- Both must be design-ready so they can attach to the same approval or creation pipeline later. +- **Invite link (tokenized):** unique link per invitee; submission or account creation tied to the token. +- **OIDC first-login (JIT):** first OIDC login creates/links a User and optionally a Member from IdP data. +- Both must be design-ready so they can attach to the same approval/creation pipeline later. --- -## 5. Evaluation of the Proposed Concept Draft +## 5. Concept Evaluation — adopted decisions -**Adopted and reflected above:** +- **Naming:** resource **JoinRequest** (one resource, status + audit timestamps). +- **No User/Member from `/join`:** only a JoinRequest, created on submit (`pending_confirmation`), updated to `submitted` on confirmation. Member/User domain unchanged. +- **Public actions:** `submit` (create with `pending_confirmation` + send email) and `confirm` (update to `submitted`). +- **Public paths:** `/join` explicitly added to the plug's public path list; `/confirm_join/:token` covered by the existing `/confirm*` rule. +- **Minimal data:** email technically required; other fields from the admin-configured set, with optional "required" per field. +- **Security:** honeypot + rate limiting in MVP; email confirmation before "submitted"; token stored as hash; 24h retention + hard-delete for expired pending. -- **Naming:** Resource name **JoinRequest** (one resource, status + audit timestamps). -- **No User/Member from `/join`:** Only a JoinRequest; record is **created on form submit** (status `pending_confirmation`) and **updated to** `submitted` on confirmation. Abuse surface and policy complexity stay low. -- **Dedicated resource and actions:** New resource `JoinRequest` with public actions: **submit** (create with `pending_confirmation` + send email) and **confirm** (update to `submitted`). Member/User domain unchanged. -- **Public paths:** `/join` is **explicitly** added to the page-permission plug’s public path list; confirmation route `/confirm_join/:token` is covered by existing `/confirm*` rule. -- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional "required" per field. -- **Security:** Honeypot + rate limiting in MVP; email confirmation before treating request as submitted; token stored as hash; 24h retention and hard-delete for expired pending. -- **Tests:** Unauthenticated GET `/join` → 200; submit creates one JoinRequest (`pending_confirmation`); confirm updates it to `submitted`; idempotent confirm; honeypot and rate limiting covered; public-path tests updated. - -**Refinements in this document:** - -- Approval as Step 2; User creation after approval left open for later. -- Admin configurability: join form settings as own section; detailed UX in a subtask. -- Three entry paths (public, invite, OIDC) and their place in the roadmap made explicit. -- Pre-confirmation store: DB only, one resource, 24h retention, hard-delete. -- Payload: typed email (required), first_name, last_name; rest in jsonb with schema_version; rationale and what it depends on documented. +Refinements layered in this document: approval as Step 2 (User creation after approval left open); join-form settings as their own section (detailed UX in a subtask); three entry paths placed in the roadmap; pre-confirmation store DB-only with 24h hard-delete; payload split typed (email/first_name/last_name) + jsonb with schema_version. --- @@ -191,90 +156,60 @@ Implementation spec for Subtask 5. **Decided:** -- **Email confirmation (double opt-in):** JoinRequest is **created on form submit** with status `pending_confirmation` and **updated to** `submitted` when the user clicks the confirmation link. Double opt-in is preserved (we only treat as "submitted" after the link is clicked). Existing confirmation pattern (AshAuthentication) is reused for token + email sender + route. +- **Email confirmation (double opt-in):** JoinRequest created on submit (`pending_confirmation`), updated to `submitted` on link click; treated as submitted only after the click. Reuses the existing AshAuthentication pattern (token + email sender + route). - **Naming:** **JoinRequest**. -- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron). -- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it. -- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page. -- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). -- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**. -- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). -- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. -- **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set. -- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail). -- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately. +- **Pre-confirmation store:** DB only, same resource; no ETS, no stateless token. Token stored as **hash**; raw token only in the email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (Oban cron) — see `lib/mix/tasks/join_requests.cleanup_expired.ex`. +- **Confirmation route:** **`/confirm_join/:token`** so `starts_with?(path, "/confirm")` covers it. +- **Public path for `/join`:** explicitly add `/join` to the plug's `public_path?/1` (e.g. in `CheckPagePermission`). +- **JoinRequest schema:** status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for the rest. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User). Idempotent confirm (unique constraint on token hash, or update only when status is `pending_confirmation`). +- **Approval outcome:** admin-configurable; default Member only (no User); optional "create User on approval" left for later. +- **Rate limiting:** honeypot + rate limiting from the start (e.g. Hammer.Plug). +- **Settings:** own section "Onboarding / Join"; `join_form_enabled` + field selection; display as list/badges; detailed UX in a separate subtask. +- **Approval permission:** normal_user; JoinRequest read/update and the approval page added to normal_user; no new permission set. +- **Approval route:** `/join_requests` (list), `/join_requests/:id` (detail). +- **Resend confirmation:** if not in Prio 1, create a separate ticket immediately. -**Open for later:** - -- Abuse metadata (IP hash etc.): classification and whether to store in Prio 1. -- "Create User on approval" option: to be specified when implemented. -- Invite link and OIDC JIT entry paths. +**Open for later:** abuse metadata (IP hash etc.) classification and whether to store in Prio 1; "create User on approval" option (specify when implemented); invite link and OIDC JIT entry paths. --- ## 7. Definition of Done (Prio 1) -- Public `/join` page and confirmation route reachable without login; **`/join` explicitly** in public paths (plug and tests). -- Flow: form submit → **JoinRequest created** in status `pending_confirmation` → confirmation email sent → user clicks link → **JoinRequest updated** to status `submitted`; no User or Member created by this flow. +- Public `/join` page and confirmation route reachable without login; `/join` explicitly in public paths (plug + tests). +- Flow: submit → JoinRequest `pending_confirmation` → email sent → click link → JoinRequest `submitted`; no User/Member created. - Anti-abuse: honeypot and rate limiting implemented and tested. -- Cleanup: scheduled job hard-deletes JoinRequests in `pending_confirmation` older than 24h (or configured retention). -- Page-permission and routing tests updated (including public-path coverage for `/join` and `/confirm_join/:token`). -- Concept and decisions (§6) documented for use in implementation specs. +- Cleanup: scheduled job hard-deletes `pending_confirmation` JoinRequests older than 24h. +- Page-permission and routing tests updated (public-path coverage for `/join` and `/confirm_join/:token`). +- Concept and decisions (§6) documented for implementation specs. --- ## 8. Implementation Plan (Subtasks) -**Resend confirmation** remains a separate ticket (see §2.5, §6). +Resend confirmation remains a separate ticket (§2.5, §6). -### Prio 1 – Public Join (4 subtasks) +**Prio 1 – Public Join (4 subtasks, all shipped):** -#### 1. JoinRequest resource and public policies **(done)** +1. **JoinRequest resource and public policies** *(shipped)* — Ash resource per §2.3.2 (status, email required, first_name/last_name, form_data jsonb, schema_version, confirmation_token_hash + expiry, audit timestamps, source); migration; unique_index on confirmation_token_hash for idempotency. Public actions `submit` (create) and `confirm` (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`. +2. **Submit and confirm flow** *(shipped)* — submit creates JoinRequest + sends confirmation email (reuse AshAuthentication sender); `/confirm_join/:token` verifies token (hash + lookup), updates to `submitted`, sets submitted_at, invalidates token (idempotent if already submitted); Oban hard-delete job for expired `pending_confirmation`. +3. **Admin: Join form settings** *(shipped)* — "Onboarding / Join" settings section (§2.6): `join_form_enabled`, field selection (member_fields + custom fields), "required" per field; persisted; **server-side allowlist** available to subtask 4. +4. **Public join page and anti-abuse** *(shipped)* — public `/join` route added to the plug's public path list; LiveView with fields from the allowlist; copy per §2.5; honeypot + rate limiting (Hammer.Plug); after-submit and expired-link copy; public-path tests updated to include `/join`. -- **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency). -- **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`. -- **Boundary:** No UI, no emails – only resource, persistence, and actions callable with nil actor. -- **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update). +**Order and dependencies:** 1 → 2 (flow uses the resource); 3 before/parallel with 4 (form reads the allowlist from settings; MVP subtask 4 can use a default allowlist with 3 following shortly). Recommended: 1 → 2 → 3 → 4. -#### 2. Submit and confirm flow **(done)** +**Step 2 – Approval (1 subtask, shipped):** -- **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h. -- **Boundary:** No join-form UI, no admin settings – only backend create/update and email/route. -- **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases. - -#### 3. Admin: Join form settings **(done)** - -- **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed). -- **Boundary:** No public form – only save/load of config and **server-side allowlist** for use in subtask 4. -- **Done:** Settings save/load; allowlist available in backend for join form; tests. - -#### 4. Public join page and anti-abuse **(done)** - -- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plug’s public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`. -- **Boundary:** No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2). -- **Done:** Unauthenticated GET `/join` → 200; submit creates JoinRequest (pending_confirmation) and sends email; confirm updates to submitted; honeypot and rate limit tested; public-path tests updated. - -### Order and dependencies - -- **1 → 2:** Submit/confirm flow uses JoinRequest resource. -- **3 before or in parallel with 4:** Form reads allowlist from settings; for MVP, subtask 4 can use a default allowlist and 3 can follow shortly after. -- **Recommended order:** **1** → **2** → **3** → **4** (or 3 in parallel with 2 if two people work on it). - -### Step 2 – Approval (1 subtask, later) - -#### 5. Approval UI (Vorstand) - -- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1. -- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency). -- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation. +5. **Approval UI (Vorstand)** *(shipped)* — routes `/join_requests` (list) → `JoinRequestLive.Index`, `/join_requests/:id` (detail) → `JoinRequestLive.Show`; full spec in §3.1. Lists submitted JoinRequests, approve/reject; on approve creates a Member (no User in MVP). Permission: normal_user has JoinRequest read/update and the two pages in PermissionSets; audit fields populated; promotion JoinRequest → Member via `Mv.Membership.approve_join_request/2` per §3.1. --- ## 9. References -- `docs/roles-and-permissions-architecture.md` – Permission sets, roles, page permissions. -- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user). -- `lib/mv_web/plugs/check_page_permission.ex` – Public path list; **add `/join`** in `public_path?/1`. -- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` – Existing confirmation-email pattern (token, link, Mailer). -- Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) – Rate limiting for Phoenix/Plug. -- Issue #308 – Original feature/planning context. +- `docs/roles-and-permissions-architecture.md` — permission sets, roles, page permissions. +- `docs/page-permission-route-coverage.md` — public paths, plug behaviour, tests; covers `/join_requests` and `/join_requests/:id` for Step 2 (normal_user). +- `lib/mv_web/plugs/check_page_permission.ex` — public path list; add `/join` in `public_path?/1`. +- `lib/mv/authorization/checks/actor_is_nil.ex` — the actor:nil public-action check. +- `lib/mix/tasks/join_requests.cleanup_expired.ex` — hard-delete of expired `pending_confirmation` JoinRequests (24h retention). +- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` — existing confirmation-email pattern (token, link, Mailer). +- Hammer / Hammer.Plug (hexdocs.pm/hammer) — rate limiting for Phoenix/Plug. +- Issue #308 — original feature/planning context. From 0b36a43edc7a0568f8dc4a1467de680d6276f8b0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 21:53:36 +0200 Subject: [PATCH 31/63] docs(db): refresh, condense and align database and groups docs --- docs/custom-fields-search-performance.md | 244 +---- docs/database-schema-readme.md | 523 ++------- docs/database_schema.dbml | 214 +++- docs/groups-architecture.md | 1254 ++-------------------- 4 files changed, 360 insertions(+), 1875 deletions(-) diff --git a/docs/custom-fields-search-performance.md b/docs/custom-fields-search-performance.md index 3987c85..47de308 100644 --- a/docs/custom-fields-search-performance.md +++ b/docs/custom-fields-search-performance.md @@ -2,242 +2,88 @@ ## Current Implementation -The search vector includes custom field values via database triggers that: -1. Aggregate all custom field values for a member -2. Extract values from JSONB format -3. Add them to the search_vector with weight 'C' +The member `search_vector` includes custom field values via database triggers that aggregate all of a member's custom field values, extract the value from each JSONB record (`value->>'_union_value'`), and add them at weight `C`. -## Performance Considerations +Two triggers maintain the vector: -### 1. Trigger Performance on Member Updates +- `members_search_vector_trigger()` — fires on `members` INSERT/UPDATE; runs a subquery `SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id`. +- `update_member_search_vector_from_custom_field_value()` — fires on `custom_field_values` INSERT/UPDATE/DELETE; re-aggregates and updates the member's `search_vector`. -**Current Implementation:** -- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE: - ```sql - SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id - ``` +Both rely on `custom_field_values_member_id_idx`, so the per-member aggregation is an indexed lookup. -**Performance Impact:** -- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`) -- ✅ **Good:** Subquery only runs for the affected member -- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower -- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead +## Applied Trigger Optimizations -**Expected Performance:** -- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation) -- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation) -- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation) +`update_member_search_vector_from_custom_field_value()` was optimized: -### 2. Trigger Performance on Custom Field Value Changes +- **Fetch only required member fields** (first_name, last_name, email, etc.) instead of the full record — reduces per-call overhead by roughly 30–50%. +- **Early return on UPDATE when the value is unchanged** — skips the expensive re-aggregation entirely. -**Current Implementation:** -- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values` -- **Optimized:** Only fetches required member fields (not full record) to reduce overhead -- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed -- Aggregates all custom field values, then updates member search_vector +Measured effect per custom-field-value change: -**Performance Impact:** -- ✅ **Good:** Index on `member_id` ensures fast lookup -- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record -- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return) -- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency) -- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row +| Case | Before | After | +|------|--------|-------| +| Value changed | 5–15 ms | 3–10 ms | +| Value unchanged (UPDATE) | 5–15 ms | < 1 ms (early return) | -**Expected Performance:** -- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms) -- **Single operation (value unchanged):** <1ms (early return, no aggregation) -- **Bulk operations:** Could be slow (consider disabling trigger temporarily) +Re-aggregation is still required whenever a value actually changes — that is necessary for `search_vector` consistency. -### 3. Search Vector Size +## Search Vector Size -**Current Constraints:** -- String values: max 10,000 characters per custom field -- No limit on number of custom fields per member -- tsvector has no explicit size limit, but very large vectors can cause issues +- String custom field values are capped at **10,000 characters each**; there is no cap on the number of custom fields per member. +- `tsvector` has no hard size limit, but very large vectors (> ~100 KB) degrade GIN index maintenance, tsvector operations, and trigger time. Worst case: 100 fields × 10,000 chars ≈ 1 MB of aggregated text for one member. +- **Recommendation:** monitor `search_vector` size in production; consider capping total custom-field content per member if large vectors appear. -**Potential Issues:** -- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB -- **Practical concern:** Very large search vectors (> 100KB) can slow down: - - Index updates (GIN index maintenance) - - Search queries (tsvector operations) - - Trigger execution time +## Bulk Imports -**Recommendation:** -- Monitor search_vector size in production -- Consider limiting total custom field content per member if needed -- PostgreSQL can handle large tsvectors, but performance degrades gradually +The custom-field-value trigger fires once per row, so importing many members with custom fields is expensive. For bulk imports, **temporarily disable the `custom_field_values` trigger**, then re-aggregate `search_vector` in a batch after the import. The initial backfill migration also updates all members in a single transaction (table lock); for > 10,000 members, batch the backfill and run during a maintenance window. -### 4. Initial Migration Performance +## Search Query Structure -**Current Implementation:** -- Updates ALL members in a single transaction: - ```sql - UPDATE members m SET search_vector = ... (subquery for each member) - ``` +Full-text search uses the GIN index on `search_vector` (fast). Substring/custom-field matching adds `EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...)` subqueries, which are **not indexed** on the JSONB value (sequential scan) and run even when the FTS branch already matches. This is the main known weakness; it is acceptable at the current scale (< 30 custom fields/member, < 10,000 members) but is the first thing to revisit if search slows. -**Performance Impact:** -- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes -- ⚠️ **Potential Issue:** Single transaction locks the members table -- ⚠️ **Potential Issue:** If migration fails, entire rollback required +## Search Filter Functions -**Recommendation:** -- For large datasets (> 10,000 members), consider: - - Batch updates (e.g., 1000 members at a time) - - Run during maintenance window - - Monitor progress +The search query in `lib/membership/member.ex` is split into modular filter builders, combined as a single OR-chain in priority order: -### 5. Search Query Performance +1. `build_fts_filter/1` — full-text search (highest priority, GIN-indexed, fastest). +2. `build_substring_filter/2` — `ILIKE` substring matching on structured fields (postal_code, house_number, email, city, country). +3. `build_custom_field_filter/1` — JSONB custom-field value matching via `EXISTS` subquery. +4. `build_fuzzy_filter/2` — trigram fuzzy matching on first_name, last_name, street (pg_trgm). -**Current Implementation:** -- Full-text search uses GIN index on `search_vector` (fast) -- Additional LIKE queries on `custom_field_values` for substring matching: - ```sql - EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...) - ``` - -**Performance Impact:** -- ✅ **Good:** GIN index on `search_vector` is very fast -- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan) -- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found -- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow - -**Expected Performance:** -- **With GIN index match:** Very fast (< 10ms for typical queries) -- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size) -- **Worst case:** Sequential scan of all custom_field_values for all members - -## Recommendations - -### Short-term (Current Implementation) - -1. **Monitor Performance:** - - Add logging for trigger execution time - - Monitor search_vector size distribution - - Track search query performance - -2. **Index Verification:** - - Ensure `custom_field_values_member_id_idx` exists and is used - - Verify GIN index on `search_vector` is maintained - -3. **Bulk Operations:** - - For bulk imports, consider temporarily disabling the custom_field_values trigger - - Re-enable and update search_vectors in batch after import - -### Medium-term Optimizations - -1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):** - - ✅ Only fetch required member fields instead of full record (reduces overhead) - - ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization) - -2. **Limit Search Vector Size:** - - Truncate very long custom field values (e.g., first 1000 chars) - - Add warning if aggregated text exceeds threshold - -3. **Optimize LIKE Queries:** - - Consider adding a generated column for searchable text - - Or use a materialized view for custom field search - -### Long-term Considerations - -1. **Alternative Approaches:** - - Separate search index table for custom fields - - Use Elasticsearch or similar for advanced search - - Materialized view for search optimization - -2. **Scaling Strategy:** - - If performance becomes an issue with 100+ custom fields per member: - - Consider limiting which custom fields are searchable - - Use a separate search service - - Implement search result caching - -## Performance Benchmarks (Estimated) - -Based on typical PostgreSQL performance: - -| Scenario | Members | Custom Fields/Member | Expected Impact | -|----------|---------|---------------------|-----------------| -| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) | -| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) | -| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) | -| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) | +Priority: **FTS > Substring > Custom Fields > Fuzzy**. ## Monitoring Queries ```sql --- Check search_vector size distribution -SELECT - pg_size_pretty(octet_length(search_vector::text)) as size, - COUNT(*) as member_count +-- search_vector size distribution +SELECT + pg_size_pretty(octet_length(search_vector::text)) AS size, + COUNT(*) AS member_count FROM members WHERE search_vector IS NOT NULL GROUP BY octet_length(search_vector::text) ORDER BY octet_length(search_vector::text) DESC LIMIT 20; --- Check average custom fields per member -SELECT - AVG(custom_field_count) as avg_custom_fields, - MAX(custom_field_count) as max_custom_fields +-- average / max custom fields per member +SELECT + AVG(custom_field_count) AS avg_custom_fields, + MAX(custom_field_count) AS max_custom_fields FROM ( - SELECT member_id, COUNT(*) as custom_field_count + SELECT member_id, COUNT(*) AS custom_field_count FROM custom_field_values GROUP BY member_id ) subq; --- Check trigger execution time (requires pg_stat_statements) -SELECT - mean_exec_time, - calls, - query +-- trigger execution time (requires pg_stat_statements) +SELECT mean_exec_time, calls, query FROM pg_stat_statements WHERE query LIKE '%members_search_vector_trigger%' ORDER BY mean_exec_time DESC; ``` -## Code Quality Improvements (Post-Review) - -### Refactored Search Implementation - -The search query has been refactored for better maintainability and clarity: - -**Before:** Single large OR-chain with mixed search types (hard to maintain) - -**After:** Modular functions grouped by search type: -- `build_fts_filter/1` - Full-text search (highest priority, fastest) -- `build_substring_filter/2` - Substring matching on structured fields -- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE) -- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets - -**Benefits:** -- ✅ Clear separation of concerns -- ✅ Easier to maintain and test -- ✅ Better documentation of search priority -- ✅ Easier to optimize individual search types - -**Search Priority Order:** -1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector -2. **Substring** - For structured fields (postal_code, phone_number, etc.) -3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching) -4. **Fuzzy Matching** - Trigram similarity for names and streets - -## Conclusion - -The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed. - -**Key Strengths:** -- Indexed lookups (member_id index) -- Efficient GIN index for search -- Trigger-based automatic updates -- Modular, maintainable search code structure - -**Key Weaknesses:** -- LIKE queries on JSONB (not indexed) -- Re-aggregation on every custom field change (necessary for consistency) -- Potential size issues with many/large custom fields -- Substring searches (contains/ILIKE) not index-optimized - -**Recent Optimizations:** -- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%) -- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms) -- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes) +## Future Options (if scale demands) +- Generated/searchable text column or materialized view for custom-field substring search (to escape the unindexed JSONB `LIKE`). +- Limit which custom fields are searchable, or truncate long values. +- External search service (e.g., Elasticsearch) for advanced search. diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index fa6ea55..a7bfb1a 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -4,105 +4,54 @@ This document provides a comprehensive overview of the Mila Membership Management System database schema. -## Quick Links +- **DBML file:** [`database_schema.dbml`](./database_schema.dbml) — full per-column intent notes and relationship edges. +- **Search-vector performance:** see [`custom-fields-search-performance.md`](./custom-fields-search-performance.md) for trigger cost analysis and tuning. -- **DBML File:** [`database_schema.dbml`](./database_schema.dbml) -- **Visualize Online:** - - [dbdiagram.io](https://dbdiagram.io) - Upload the DBML file - - [dbdocs.io](https://dbdocs.io) - Generate interactive documentation +The DBML is **hand-maintained** (not auto-generated); keep it in sync with `priv/repo/migrations/`. ## Schema Statistics | Metric | Count | |--------|-------| -| **Tables** | 11 | +| **Tables** | 12 | | **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) | -| **Relationships** | 9 | -| **Indexes** | 25+ | -| **Triggers** | 1 (Full-text search) | +| **Triggers** | 3 (member, custom_field_values, member_groups → member search-vector) | ## Tables Overview ### Accounts Domain +- **`users`** — authentication accounts. Dual auth (Password + OIDC), optional 1:1 link to a member; email is the source of truth when linked. +- **`tokens`** — JWT storage for AshAuthentication; multiple purposes, revocation by deletion. -#### `users` -- **Purpose:** User authentication and session management -- **Rows (Estimated):** Low to Medium (typically 10-50% of members) -- **Key Features:** - - Dual authentication (Password + OIDC) - - Optional 1:1 link to members - - Email as source of truth when linked - -#### `tokens` -- **Purpose:** JWT token storage for AshAuthentication -- **Rows (Estimated):** Medium to High (multiple tokens per user) -- **Key Features:** - - Token lifecycle management - - Revocation support - - Multiple token purposes +OIDC account linking is recorded on the `users` table via the `oidc_id` column; there is no separate `user_identities` table. ### Membership Domain - -#### `members` -- **Purpose:** Club member master data -- **Rows (Estimated):** High (core entity) -- **Key Features:** - - Complete member profile - - Full-text search via tsvector - - Bidirectional email sync with users - - Flexible address and contact data - -#### `custom_field_values` -- **Purpose:** Dynamic custom member attributes -- **Rows (Estimated):** Variable (N per member) -- **Key Features:** - - Union type value storage (JSONB) - - Multiple data types supported - - One custom field value per custom field per member - -#### `custom_fields` -- **Purpose:** Schema definitions for custom_field_values -- **Rows (Estimated):** Low (admin-defined) -- **Key Features:** - - Type definitions - - Immutable and required flags - - Centralized custom field management - -#### `settings` -- **Purpose:** Global application settings (singleton resource) -- **Rows (Estimated):** 1 (singleton pattern) -- **Key Features:** - - Club name configuration - - Member field visibility settings - - Membership fee default settings - - Environment variable support for club name - -#### `groups` -- **Purpose:** Group definitions for organizing members -- **Rows (Estimated):** Low (typically 5-20 groups per club) -- **Key Features:** - - Unique group names (case-insensitive) - - URL-friendly slugs (auto-generated, immutable) - - Optional descriptions - - Many-to-many relationship with members - -#### `member_groups` -- **Purpose:** Join table for many-to-many relationship between members and groups -- **Rows (Estimated):** Medium to High (multiple groups per member) -- **Key Features:** - - Unique constraint on (member_id, group_id) - - CASCADE delete on both sides - - Efficient indexes for queries +- **`members`** — club member master data. Full-text + fuzzy search, bidirectional email sync with users, flexible address/contact data, `country`, optional `vereinfacht_contact_id` (external vereinfacht.de contact). +- **`custom_field_values`** — dynamic per-member attributes. Union-type value in JSONB; one value per custom field per member. +- **`custom_fields`** — schema definitions for custom field values (type, `required`/`show_in_overview` flags, optional `join_description`, auto-generated slug). +- **`settings`** — global application settings (singleton). Club name (also via `ASSOCIATION_NAME` env), member-field visibility/required maps, fee defaults, plus OIDC, SMTP/mail-from, vereinfacht.de, public join-form, `registration_enabled`, and `oidc_only` configuration. See [Settings configuration columns](#settings-configuration-columns). +- **`groups`** — member groupings. Case-insensitive-unique names, auto-generated immutable slugs, optional descriptions; many-to-many with members. +- **`member_groups`** — join table for members ↔ groups. Unique `(member_id, group_id)`, CASCADE delete on both sides (join table only). +- **`join_requests`** — public join flow (onboarding, double opt-in). Status machine `pending_confirmation → submitted → approved/rejected`; confirmation token stored as hash only, ~24h retention for unconfirmed records. ### Authorization Domain +- **`roles`** — RBAC. Links users to one of four hardcoded permission sets (`own_data`, `read_only`, `normal_user`, `admin`); system roles are deletion-protected. -#### `roles` -- **Purpose:** Role-based access control (RBAC) -- **Rows (Estimated):** Low (typically 3-10 roles) -- **Key Features:** - - Links users to permission sets - - System role protection - - Four hardcoded permission sets: own_data, read_only, normal_user, admin +### MembershipFees Domain +- **`membership_fee_types`** — fee types with immutable billing interval. +- **`membership_fee_cycles`** — per-member billing cycles with payment status. + +## Settings configuration columns + +The singleton `settings` row carries runtime configuration (all nullable unless noted). Grouped by area: + +- **Member overview:** `member_field_visibility` (JSONB; absent key = visible), `member_field_required` (JSONB). +- **Membership fees:** `include_joining_cycle` (bool, NOT NULL, default true), `default_membership_fee_type_id` (FK → membership_fee_types, ON DELETE SET NULL). +- **Registration / login:** `registration_enabled` (bool, NOT NULL, default true), `oidc_only` (bool, NOT NULL, default false). +- **OIDC:** `oidc_client_id`, `oidc_client_secret`, `oidc_base_url`, `oidc_redirect_uri`, `oidc_admin_group_name`, `oidc_groups_claim`. +- **SMTP / mail-from:** `smtp_host`, `smtp_port` (bigint), `smtp_username`, `smtp_password`, `smtp_ssl`, `smtp_from_name`, `smtp_from_email`. +- **vereinfacht.de:** `vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`. +- **Public join form:** `join_form_enabled` (bool, NOT NULL, default false), `join_form_field_ids` (text[]), `join_form_field_required` (JSONB). ## Key Relationships @@ -124,123 +73,54 @@ Member (N) ←→ (N) Group Settings (1) → MembershipFeeType (0..1) ``` -### Relationship Details +## Foreign Key On-Delete Behavior -1. **User ↔ Member (Optional 1:1, both sides optional)** - - A User can have 0 or 1 Member (`user.member_id` can be NULL) - - A Member can have 0 or 1 User (optional `has_one` relationship) - - Both entities can exist independently - - Email synchronization when linked (User.email is source of truth) - - `ON DELETE SET NULL` on user side (User preserved when Member deleted) +| Relationship | On Delete | Rationale | +|--------------|-----------|-----------| +| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted | +| `users.role_id → roles.id` | RESTRICT | Cannot delete a role that still has users | +| `custom_field_values.member_id → members.id` | CASCADE | Delete values with member | +| `custom_field_values.custom_field_id → custom_fields.id` | CASCADE | Delete values when the custom field is deleted | +| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type assigned to members | +| `membership_fee_cycles.member_id → members.id` | CASCADE | Cycles deleted with member | +| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type with cycles | +| `settings.default_membership_fee_type_id → membership_fee_types.id` | SET NULL | Clear default if fee type deleted | +| `member_groups.member_id → members.id` | CASCADE | Association removed; member preserved | +| `member_groups.group_id → groups.id` | CASCADE | Association removed; group preserved | -2. **User → Role (N:1)** - - Many users can be assigned to one role - - `ON DELETE RESTRICT` - cannot delete role if users are assigned - - Role links user to permission set for authorization +`join_requests.reviewed_by_user_id` is intentionally **unconstrained** (no FK); `reviewed_by_display` is denormalized so the UI need not load the reviewer User. -3. **Member → CustomFieldValues (1:N)** - - One member, many custom_field_values - - `ON DELETE CASCADE` - custom_field_values deleted with member - - Composite unique constraint (member_id, custom_field_id) - -4. **CustomFieldValue → CustomField (N:1)** - - Custom field values reference type definition - - `ON DELETE RESTRICT` - cannot delete type if in use - - Type defines data structure - -5. **Member → MembershipFeeType (N:1, optional)** - - Many members can be assigned to one fee type - - `ON DELETE RESTRICT` - cannot delete fee type if members are assigned - - Optional relationship (member can have no fee type) - -6. **Member → MembershipFeeCycles (1:N)** - - One member, many billing cycles - - `ON DELETE CASCADE` - cycles deleted when member deleted - - Unique constraint (member_id, cycle_start) - -7. **MembershipFeeCycle → MembershipFeeType (N:1)** - - Many cycles reference one fee type - - `ON DELETE RESTRICT` - cannot delete fee type if cycles exist - -8. **Settings → MembershipFeeType (N:1, optional)** - - Settings can reference a default fee type - - `ON DELETE SET NULL` - if fee type is deleted, setting is cleared - -9. **Member ↔ Group (N:N via MemberGroup)** - - Many-to-many relationship through `member_groups` join table - - `ON DELETE CASCADE` on both sides - removing member/group removes associations - - Unique constraint on (member_id, group_id) prevents duplicates - - Groups searchable via member search vector +**User ↔ Member** is an optional 1:1 (both sides may be NULL; entities exist independently). **Member ↔ Group** is many-to-many through `member_groups` (CASCADE lives only on the join table). ## Important Business Rules ### Email Synchronization -- **User.email** is the source of truth when linked -- On linking: Member.email ← User.email (overwrite) -- After linking: Changes sync bidirectionally -- Validation prevents email conflicts +- **User.email is the source of truth when linked.** On linking, `Member.email ← User.email` (overwrite). Afterwards changes sync bidirectionally. Validation prevents email conflicts with other unlinked users. ### Authentication Strategies -- **Password:** Email + hashed_password -- **OIDC:** Email + oidc_id (Rauthy provider) -- At least one method required per user +- **Password:** email + hashed_password. **OIDC:** email + oidc_id (Rauthy provider), the external identity recorded via the `oidc_id` column on `users`. At least one method required per user. ### Member Constraints -- First name and last name required (min 1 char) -- Email unique, validated format (5-254 chars) -- Exit date must be after join date -- Phone: `+?[0-9\- ]{6,20}` -- Postal code: optional (no format validation) -- Country: optional +- `first_name` / `last_name`: optional, but if present min 1 char. +- `email`: unique, validated format (5–254 chars). +- `exit_date` must be after `join_date`. +- `postal_code`, `country`: optional, no format validation. ### CustomFieldValue System -- Maximum one custom field value per custom field per member -- Value stored as union type in JSONB -- Supported types: string, integer, boolean, date, email -- Types can be marked as immutable or required - -## Indexes - -### Performance Indexes - -**members:** -- `search_vector` (GIN) - Full-text search (tsvector) -- `first_name` (GIN trgm) - Fuzzy search on first name -- `last_name` (GIN trgm) - Fuzzy search on last name -- `email` (GIN trgm) - Fuzzy search on email -- `city` (GIN trgm) - Fuzzy search on city -- `street` (GIN trgm) - Fuzzy search on street -- `notes` (GIN trgm) - Fuzzy search on notes -- `email` (B-tree) - Exact email lookups -- `last_name` (B-tree) - Name sorting -- `join_date` (B-tree) - Date filtering - -**custom_field_values:** -- `member_id` - Member custom field value lookups -- `custom_field_id` - Type-based queries -- Composite `(member_id, custom_field_id)` - Uniqueness - -**tokens:** -- `subject` - User token lookups -- `expires_at` - Token cleanup -- `purpose` - Purpose-based queries - -**users:** -- `email` (unique) - Login lookups -- `oidc_id` (unique) - OIDC authentication -- `member_id` (unique) - Member linkage +- One value per custom field per member. Value stored as a union type in JSONB: `{type: "string|integer|boolean|date|email", value: }`. Custom fields can be marked `required` and toggled `show_in_overview`. ## Full-Text Search ### Implementation -- **Trigger** on `members` (INSERT/UPDATE): runs function `members_search_vector_trigger()` +- **Trigger** on `members` (INSERT/UPDATE): `update_search_vector` runs function `members_search_vector_trigger()` +- **Trigger** on `custom_field_values` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_custom_field_value_change` runs function `update_member_search_vector_from_custom_field_value()` - **Trigger** on `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()` - **Index Type:** GIN (Generalized Inverted Index) ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes, group names (from member_groups → groups) -- **Weight C:** city, street, house_number, postal_code, country, custom_field_values +- **Weight C:** city, street, house_number, postal_code, custom_field_values - **Weight D (lowest):** join_date, exit_date ### Group Names in Search @@ -258,291 +138,46 @@ Custom field values are automatically included in the search vector: ### Usage Example ```sql -SELECT * FROM members +SELECT * FROM members WHERE search_vector @@ to_tsquery('simple', 'john & doe'); ``` ## Fuzzy Search (Trigram-based) -### Implementation -- **Extension:** `pg_trgm` (PostgreSQL Trigram) -- **Index Type:** GIN with `gin_trgm_ops` operator class -- **Similarity Threshold:** 0.2 (default, configurable) -- **Added:** November 2025 (PR #187, closes #162) +- **Extension:** `pg_trgm`; GIN indexes with `gin_trgm_ops` on `first_name`, `last_name`, `email`, `city`, `street`, `notes`. +- **Similarity threshold:** 0.2 (default, configurable) — balances precision/recall. +- **Added:** November 2025 (PR #187, closes #162). -### How It Works -Fuzzy search combines multiple search strategies: -1. **Full-text search** - Primary filter using tsvector -2. **Trigram similarity** - `similarity(field, query) > threshold` -3. **Word similarity** - `word_similarity(query, field) > threshold` -4. **Substring matching** - `LIKE` and `ILIKE` for exact substrings -5. **Modulo operator** - `query % field` for quick similarity check +Fuzzy search combines several strategies (applied as an OR-chain alongside full-text and substring matching): -### Indexed Fields for Fuzzy Search -- `first_name` - GIN trigram index -- `last_name` - GIN trigram index -- `email` - GIN trigram index -- `city` - GIN trigram index -- `street` - GIN trigram index -- `notes` - GIN trigram index +1. Full-text search — primary filter via tsvector. +2. Trigram similarity — `similarity(field, query) > threshold`. +3. Word similarity — `word_similarity(query, field) > threshold`. +4. Substring matching — `LIKE` / `ILIKE`. +5. `%` operator — quick trigram-similarity check. -### Usage Example (Ash Action) -```elixir -# In LiveView or context -Member.fuzzy_search(Member, query: "john", similarity_threshold: 0.2) - -# Or using Ash Query directly -Member -|> Ash.Query.for_read(:search, %{query: "john", similarity_threshold: 0.2}) -|> Mv.Membership.read!() -``` - -### Usage Example (SQL) -```sql --- Trigram similarity search -SELECT * FROM members -WHERE similarity(first_name, 'john') > 0.2 - OR similarity(last_name, 'doe') > 0.2 -ORDER BY similarity(first_name, 'john') DESC; - --- Word similarity (better for partial matches) -SELECT * FROM members -WHERE word_similarity('john', first_name) > 0.2; - --- Quick similarity check with % operator -SELECT * FROM members -WHERE 'john' % first_name; -``` - -### Performance Considerations -- **GIN indexes** speed up trigram operations significantly -- **Similarity threshold** of 0.2 balances precision and recall -- **Combined approach** (FTS + trigram) provides best results -- Lower threshold = more results but less specific +For the Elixir search action and per-strategy filter functions, see `lib/membership/member.ex` and [`custom-fields-search-performance.md`](./custom-fields-search-performance.md). ## Database Extensions -### Required PostgreSQL Extensions +Installed extensions are defined in `Mv.Repo.installed_extensions/0`: -1. **uuid-ossp** - - Purpose: UUID generation functions - - Used for: `gen_random_uuid()`, `uuid_generate_v7()` +| Extension | Purpose | Notes | +|-----------|---------|-------| +| `ash-functions` | Ash helper SQL functions | installed by Ash | +| `citext` | Case-insensitive text | `users.email` | +| `pg_trgm` | Trigram fuzzy search | added in `20251001141005_add_trigram_to_members.exs`; operators `%`, `similarity()`, `word_similarity()` | -2. **citext** - - Purpose: Case-insensitive text type - - Used for: `users.email` (case-insensitive email matching) +`gen_random_uuid()` is built into PostgreSQL; `uuid_generate_v7()` is a custom SQL function defined in a migration (not provided by an extension). -3. **pg_trgm** - - Purpose: Trigram-based fuzzy text search and similarity matching - - Used for: Fuzzy member search with similarity scoring - - Operators: `%` (similarity), `word_similarity()`, `similarity()` - - Added in: Migration `20251001141005_add_trigram_to_members.exs` +## Sensitive Data (GDPR / logging) -### Installation -```sql -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "citext"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -``` - -## Migration Strategy - -### Ash Migrations -This project uses Ash Framework's migration system: - -```bash -# Generate new migration -mix ash.codegen --name add_new_feature - -# Apply migrations -mix ash.setup - -# Rollback migrations -mix ash_postgres.rollback -n 1 -``` - -### Migration Files Location -``` -priv/repo/migrations/ -├── 20250421101957_initialize_extensions_1.exs -├── 20250528163901_initial_migration.exs -├── 20250617090641_member_fields.exs -├── 20250620110850_add_accounts_domain.exs -├── 20250912085235_AddSearchVectorToMembers.exs -├── 20250926180341_add_unique_email_to_members.exs -├── 20251001141005_add_trigram_to_members.exs -└── 20251016130855_add_constraints_for_user_member_and_property.exs -``` - -## Data Integrity - -### Foreign Key Behaviors - -| Relationship | On Delete | Rationale | -|--------------|-----------|-----------| -| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted | -| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member | -| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use | - -### Validation Layers - -1. **Database Level:** - - CHECK constraints - - NOT NULL constraints - - UNIQUE indexes - - Foreign key constraints - -2. **Application Level (Ash):** - - Custom validators - - Email format validation (EctoCommons.EmailValidator) - - Business rule validation - - Cross-entity validation - -3. **UI Level:** - - Client-side form validation - - Real-time feedback - - Error messages - -## Performance Considerations - -### Query Patterns - -**High Frequency:** -- Member search (uses GIN index on search_vector) -- Member list with filters (uses indexes on join_date, membership_fee_type_id) -- User authentication (uses unique index on email/oidc_id) -- CustomFieldValue lookups by member (uses index on member_id) - -**Medium Frequency:** -- Member CRUD operations -- CustomFieldValue updates -- Token validation - -**Low Frequency:** -- CustomField management -- User-Member linking -- Bulk operations - -### Optimization Tips - -1. **Use indexes:** All critical query paths have indexes -2. **Preload relationships:** Use Ash's `load` to avoid N+1 -3. **Pagination:** Use keyset pagination (configured by default) -4. **GIN indexes:** Full-text search and fuzzy search on multiple fields -5. **Search optimization:** Full-text search via tsvector, not LIKE - -## Visualization - -### Using dbdiagram.io - -1. Visit [https://dbdiagram.io](https://dbdiagram.io) -2. Click "Import" → "From file" -3. Upload `database_schema.dbml` -4. View interactive diagram with relationships - -### Using dbdocs.io - -1. Install dbdocs CLI: `npm install -g dbdocs` -2. Generate docs: `dbdocs build database_schema.dbml` -3. View generated documentation - -### VS Code Extension - -Install "DBML Language" extension to view/edit DBML files with: -- Syntax highlighting -- Inline documentation -- Error checking - -## Security Considerations - -### Sensitive Data - -**Encrypted:** -- `users.hashed_password` (bcrypt) - -**Should Not Log:** -- hashed_password -- tokens (jti, purpose, extra_data) - -**Personal Data (GDPR):** -- All member fields (name, email, address) -- User email -- Token subject - -### Access Control - -- Implement through Ash policies -- Row-level security considerations for future -- Audit logging for sensitive operations - -## Backup Recommendations - -### Critical Tables (Priority 1) -- `members` - Core business data -- `users` - Authentication data -- `custom_fields` - Schema definitions - -### Important Tables (Priority 2) -- `custom_field_values` - Member custom data -- `tokens` - Can be regenerated but good to backup - -### Backup Strategy -```bash -# Full database backup -pg_dump -Fc mv_prod > backup_$(date +%Y%m%d).dump - -# Restore -pg_restore -d mv_prod backup_20251110.dump -``` - -## Testing - -### Test Database -- Separate test database: `mv_test` -- Sandbox mode via Ecto.Adapters.SQL.Sandbox -- Reset between tests - -### Seed Data -```bash -# Load seed data -mix run priv/repo/seeds.exs -``` - -## Future Considerations - -### Potential Additions - -1. **Audit Log Table** - - Track changes to members - - Compliance and history tracking - -2. **Payment Tracking** - - Payment history table - - Transaction records - - Fee calculation - -3. **Document Storage** - - Member documents/attachments - - File metadata table - -4. **Email Queue** - - Outbound email tracking - - Delivery status - -5. **Roles & Permissions** - - User roles (admin, treasurer, member) - - Permission management - -## Resources - -- **Ash Framework:** [https://hexdocs.pm/ash](https://hexdocs.pm/ash) -- **AshPostgres:** [https://hexdocs.pm/ash_postgres](https://hexdocs.pm/ash_postgres) -- **DBML Specification:** [https://dbml.dbdiagram.io](https://dbml.dbdiagram.io) -- **PostgreSQL Docs:** [https://www.postgresql.org/docs/](https://www.postgresql.org/docs/) +- **Never log:** `users.hashed_password` (bcrypt), token fields (`jti`, `purpose`, `extra_data`), OIDC/SMTP/vereinfacht secrets in `settings`. +- **Personal data:** all member fields, user email, join-request applicant data. --- -**Last Updated:** 2026-01-27 -**Schema Version:** 1.5 +**Last Updated:** 2026-06-15 +**Schema Version:** 1.6 (12 tables) **Database:** PostgreSQL 17.6 (dev) / 16 (prod) diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 16c9723..f763726 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,8 +6,9 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.4 -// Last Updated: 2026-01-13 +// Version: 1.6 +// Last Updated: 2026-06-15 +// Hand-maintained (NOT auto-generated). 12 tables. Project mila_membership_management { database_type: 'PostgreSQL' @@ -25,15 +26,16 @@ Project mila_membership_management { - GDPR-compliant data management ## Domains: - - **Accounts**: User authentication and session management - - **Membership**: Club member data and custom fields + - **Accounts**: User authentication, sessions, OIDC strategy identities + - **Membership**: Club member data, custom fields, groups, settings, public join requests - **MembershipFees**: Membership fee types and billing cycles - **Authorization**: Role-based access control (RBAC) - ## Required PostgreSQL Extensions: - - uuid-ossp (UUID generation) + ## Required PostgreSQL Extensions (see Mv.Repo.installed_extensions/0): + - ash-functions (Ash helper SQL functions) - citext (case-insensitive text) - pg_trgm (trigram-based fuzzy search) + UUIDv7 ids use uuid_generate_v7(), a custom SQL function defined in a migration (not an extension). ''' } @@ -135,7 +137,8 @@ Table members { search_vector tsvector [null, note: 'Full-text search index (auto-generated)'] membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type'] membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated'] - + vereinfacht_contact_id text [null, note: 'External contact id from the vereinfacht.de API (no FK; null if unlinked)'] + indexes { email [unique, name: 'members_unique_email_index'] search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)'] @@ -169,7 +172,8 @@ Table members { **Search Capabilities:** 1. Full-Text Search (tsvector): - `search_vector` is auto-updated via trigger - - Weighted fields: first_name (A), last_name (A), email (B), notes (B) + - Weighted fields (A/B/C/D map): see the "Weighted Fields" section of + database-schema-readme.md (single source of truth, matches the search trigger) - GIN index for fast text search 2. Fuzzy Search (pg_trgm): @@ -225,7 +229,7 @@ Table custom_field_values { **Constraints:** - Each member can have only ONE custom field value per custom field - Custom field values are deleted when member is deleted (CASCADE) - - Custom field cannot be deleted if custom field values exist (RESTRICT) + - Custom field values are deleted when the custom field is deleted (CASCADE) **Use Cases:** - Custom membership numbers @@ -241,8 +245,9 @@ Table custom_fields { slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.'] value_type text [not null, note: 'Data type: string | integer | boolean | date | email'] description text [null, note: 'Human-readable description'] - immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation'] + join_description text [null, note: 'Optional label shown for this field on the public join form (e.g., a GDPR confirmation text); supports inline external links. Falls back to name when null.'] required boolean [not null, default: false, note: 'If true, all members must have this custom field'] + show_in_overview boolean [not null, default: true, note: 'If true, this custom field is displayed in the member overview table and can be sorted'] indexes { name [unique, name: 'custom_fields_unique_name_index'] @@ -259,8 +264,9 @@ Table custom_fields { - `slug`: URL-friendly, human-readable identifier (auto-generated, immutable) - `value_type`: Enforces data type consistency - `description`: Documentation for users/admins - - `immutable`: Prevents changes after initial creation (e.g., membership numbers) + - `join_description`: Optional label shown for this field on the public join form (falls back to `name` when null) - `required`: Enforces that all members must have this custom field + - `show_in_overview`: When true, the field is shown in the member overview table and can be sorted **Slug Generation:** - Automatically generated from `name` on creation @@ -275,13 +281,13 @@ Table custom_fields { - `name` must be unique across all custom fields - `slug` must be unique across all custom fields - `slug` cannot be empty (validated on creation) - - Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT) - + - Deleting a custom field cascades: its custom_field_values are deleted too (ON DELETE CASCADE) + **Examples:** - - Membership Number (string, immutable, required) → slug: "membership-number" - - Emergency Contact (string, mutable, optional) → slug: "emergency-contact" - - Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer" - - Certification Date (date, immutable, optional) → slug: "certification-date" + - Membership Number (string, required) → slug: "membership-number" + - Emergency Contact (string, optional) → slug: "emergency-contact" + - Certified Trainer (boolean, optional) → slug: "certified-trainer" + - Certification Date (date, optional) → slug: "certification-date" ''' } @@ -399,8 +405,8 @@ Ref: custom_field_values.member_id > members.id [delete: cascade] // CustomFieldValue → CustomField (N:1) // - Many custom_field_values can reference one custom field // - CustomFieldValue type defines the schema/behavior -// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist -Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict] +// - ON DELETE CASCADE: deleting the custom field deletes its custom_field_values +Ref: custom_field_values.custom_field_id > custom_fields.id [delete: cascade] // Member → MembershipFeeType (N:1) // - Many members can be assigned to one fee type @@ -462,25 +468,14 @@ Enum membership_fee_status { TableGroup accounts_domain { users tokens - + Note: ''' **Accounts Domain** - - Handles user authentication and session management using AshAuthentication. - Supports multiple authentication strategies (Password, OIDC). - ''' -} -TableGroup membership_domain { - members - custom_field_values - custom_fields - - Note: ''' - **Membership Domain** - - Core business logic for club membership management. - Supports flexible, extensible member data model. + Handles user authentication and session management using AshAuthentication. + Supports multiple authentication strategies (Password, OIDC). OIDC linking + is recorded on the users table via the oidc_id column (there is no separate + user_identities table). ''' } @@ -550,9 +545,32 @@ Table roles { Table settings { id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier'] club_name text [not null, note: 'The name of the association/club (min length: 1)'] - member_field_visibility jsonb [null, note: 'Visibility configuration for member fields in overview (JSONB map)'] + member_field_visibility jsonb [null, note: 'Visibility config for member fields in overview (JSONB map; absent key = visible)'] + member_field_required jsonb [null, note: 'Required-field config for member fields (JSONB map)'] include_joining_cycle boolean [not null, default: true, note: 'Whether to include the joining cycle in membership fee generation'] - default_membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - default fee type for new members'] + default_membership_fee_type_id uuid [null, note: 'Logical reference to membership_fee_types (default fee type for new members) - app-enforced, NO DB foreign key'] + registration_enabled boolean [not null, default: true, note: 'Whether self-service user registration is enabled'] + oidc_only boolean [not null, default: false, note: 'If true, only OIDC login is offered (password login hidden)'] + oidc_client_id text [null, note: 'OIDC client id'] + oidc_client_secret text [null, note: 'OIDC client secret'] + oidc_base_url text [null, note: 'OIDC provider base URL (e.g., Rauthy)'] + oidc_redirect_uri text [null, note: 'OIDC redirect URI'] + oidc_admin_group_name text [null, note: 'Provider group name mapped to admin role on login'] + oidc_groups_claim text [null, note: 'JWT claim carrying the user groups for role sync'] + smtp_host text [null, note: 'Outbound SMTP host'] + smtp_port bigint [null, note: 'Outbound SMTP port'] + smtp_username text [null, note: 'SMTP auth username'] + smtp_password text [null, note: 'SMTP auth password (secret)'] + smtp_ssl text [null, note: 'SMTP TLS/SSL mode'] + smtp_from_name text [null, note: 'Display name for the From header (mail_from)'] + smtp_from_email text [null, note: 'Email address for the From header (mail_from)'] + vereinfacht_api_url text [null, note: 'vereinfacht.de API base URL'] + vereinfacht_api_key text [null, note: 'vereinfacht.de API key (secret)'] + vereinfacht_club_id text [null, note: 'vereinfacht.de club identifier'] + vereinfacht_app_url text [null, note: 'vereinfacht.de app URL (for links)'] + join_form_enabled boolean [not null, default: false, note: 'Whether the public join form is enabled'] + join_form_field_ids text[] [null, note: 'Ordered custom_field ids shown on the public join form'] + join_form_field_required jsonb [null, note: 'Per-field required config for the join form (JSONB map)'] inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] @@ -590,19 +608,123 @@ Table settings { ''' } +// ============================================ +// MEMBERSHIP DOMAIN — Groups +// ============================================ + +Table groups { + id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] + name text [not null, note: 'Group name (unique case-insensitively via LOWER(name))'] + slug text [not null, unique, note: 'URL-friendly, immutable identifier auto-generated from name (shared GenerateSlug change)'] + description text [null, note: 'Optional description'] + inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] + updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] + + indexes { + slug [unique, name: 'groups_unique_slug_index', note: 'Case-sensitive unique slug'] + name [unique, name: 'groups_unique_name_lower_index', note: 'UNIQUE on LOWER(name) - case-insensitive name uniqueness'] + } + + Note: ''' + **Member Groups** + + Flat groupings of members (no hierarchy in current schema). Many-to-many + with members via member_groups. `slug` is generated by the shared + Mv.Membership.Changes.GenerateSlug change (same as custom_fields) and is + used for URL routing (/groups/:slug). Group names feed the member + search_vector at weight B (see member_groups note). + + **Future extension path (not yet in schema):** + - parent_group_id (self-referential, nullable) + circular-ref guard + path calc for hierarchy + - member_group_roles table linking MemberGroup to a Role (position within a group) + ''' +} + +Table member_groups { + id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] + member_id uuid [not null, note: 'FK to members'] + group_id uuid [not null, note: 'FK to groups'] + inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] + updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] + + indexes { + (member_id, group_id) [unique, name: 'member_groups_unique_member_group_index', note: 'One association per member per group'] + member_id [name: 'member_groups_member_id_index'] + group_id [name: 'member_groups_group_id_index'] + } + + Note: ''' + **Member ↔ Group Join Table** + + CASCADE delete on BOTH foreign keys (the cascade lives only on the join + table; members and groups themselves are never deleted by association + removal). INSERT/UPDATE/DELETE here fires the trigger that refreshes the + affected member's search_vector so group names (weight B) stay current. + ''' +} + +// ============================================ +// MEMBERSHIP DOMAIN — Public Join Requests +// ============================================ + +Table join_requests { + id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] + status text [not null, default: 'pending_confirmation', note: 'pending_confirmation → submitted (after email confirm) → approved/rejected'] + email text [not null, note: 'Applicant email'] + first_name text [null, note: 'Applicant first name'] + last_name text [null, note: 'Applicant last name'] + form_data jsonb [null, note: 'Submitted join-form field values (custom fields)'] + schema_version integer [null, note: 'Version of the join-form schema used at submission time'] + confirmation_token_hash text [null, note: 'Hash of the double-opt-in token (raw token never stored)'] + confirmation_token_expires_at timestamp [null, note: 'Token expiry (UTC)'] + confirmation_sent_at timestamp [null, note: 'When the confirmation email was sent (UTC)'] + submitted_at timestamp [null, note: 'When email was confirmed and request submitted (UTC)'] + approved_at timestamp [null, note: 'When an admin approved (UTC)'] + rejected_at timestamp [null, note: 'When an admin rejected (UTC)'] + reviewed_by_user_id uuid [null, note: 'User who reviewed (no FK constraint)'] + reviewed_by_display text [null, note: 'Reviewer display string, denormalized so the UI need not load the User'] + source text [null, note: 'Origin of the request (e.g., public form)'] + inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] + updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] + + indexes { + confirmation_token_hash [unique, name: 'join_requests_confirmation_token_hash_unique', note: 'Partial unique WHERE confirmation_token_hash IS NOT NULL'] + email [name: 'join_requests_email_index'] + status [name: 'join_requests_status_index'] + } + + Note: ''' + **Public Join Flow (Onboarding, Double Opt-In)** + + Stores public join-form submissions. Double opt-in: the confirmation token + is stored as a hash only; unconfirmed records have a ~24h retention and are + removed by a scheduled cleanup job. `reviewed_by_user_id` is intentionally + unconstrained (no FK); `reviewed_by_display` is denormalized so showing the + reviewer does not require loading the User. + ''' +} + // ============================================ // RELATIONSHIPS (Additional) // ============================================ +// MemberGroup → Member (N:1) +// - ON DELETE CASCADE (join table only): association removed, member preserved +Ref: member_groups.member_id > members.id [delete: cascade] + +// MemberGroup → Group (N:1) +// - ON DELETE CASCADE (join table only): association removed, group preserved +Ref: member_groups.group_id > groups.id [delete: cascade] + // User → Role (N:1) // - Many users can be assigned to one role // - ON DELETE RESTRICT: Cannot delete role if users are assigned Ref: users.role_id > roles.id [delete: restrict] -// Settings → MembershipFeeType (N:1, optional) -// - Settings can reference a default membership fee type -// - ON DELETE SET NULL: If fee type is deleted, setting is cleared -Ref: settings.default_membership_fee_type_id > membership_fee_types.id [delete: set null] +// Settings → MembershipFeeType (N:1, optional) — LOGICAL relationship only +// - No DB foreign key (cross-domain dependency is deliberately avoided); +// referential integrity is enforced in the app (Mv.Membership.Setting) +Ref: settings.default_membership_fee_type_id > membership_fee_types.id // ============================================ // TABLE GROUPS (Updated) @@ -624,12 +746,16 @@ TableGroup membership_domain { custom_field_values custom_fields settings - + groups + member_groups + join_requests + Note: ''' **Membership Domain** - + Core business logic for club membership management. Supports flexible, extensible member data model. - Includes global application settings (singleton). + Includes member groups (many-to-many), global application settings + (singleton), and the public join-request flow. ''' } diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index ca1f07b..0959488 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -1,1223 +1,101 @@ # Groups - Technical Architecture -**Project:** Mila - Membership Management System **Feature:** Groups Management -**Version:** 1.0 -**Last Updated:** 2025-01-XX -**Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)) +**Status:** Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)) + +This document records the durable design of the Groups feature: data model, key decisions, integration points, accessibility rules, and the planned extension paths. The original implementation plan (estimations, vertical slices, per-issue acceptance criteria, testing/migration strategy) has been removed now that the feature has shipped. + +**Related:** [database-schema-readme.md](./database-schema-readme.md), [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md). --- -## Purpose +## Core Design Decisions -This document defines the technical architecture for the Groups feature. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. +1. **Many-to-many:** members can belong to multiple groups and vice versa, via the `member_groups` join table (a separate Ash resource). +2. **Flat structure:** no hierarchy in the current schema; the design leaves a clear path to add it later (see [Future Extensibility](#future-extensibility)). +3. **Minimal attributes:** `name`, `description`, `slug`. The `slug` is auto-generated from `name`, immutable, URL-friendly. +4. **Cascade on the join table only:** deleting a group (or member) removes the `member_groups` associations but never deletes members/groups themselves. Group deletion requires explicit confirmation (typing the group name). +5. **Search integration:** group names are included in the member `search_vector` (not a separate search index). -**Related Documents:** +## Domain & Resources -- [database-schema-readme.md](./database-schema-readme.md) - Database documentation -- [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) - Authorization system +Groups live in the **`Mv.Membership`** domain alongside Members and CustomFields. ---- +- `Mv.Membership.Group` (`lib/membership/group.ex`) — attributes `name`, `slug`, `description`; `has_many :member_groups`, `many_to_many :members`; `member_count` aggregate (`count :member_count, :member_groups`); `unique_slug` identity for slug lookups. Slug is generated by the shared **`Mv.Membership.Changes.GenerateSlug`** change (the same change CustomFields uses), generated on create and immutable on update. +- `Mv.Membership.MemberGroup` (`lib/membership/member_group.ex`) — join table; `belongs_to :member`, `belongs_to :group`; unique on `(member_id, group_id)`. Has `create`/`destroy` actions only (no `update`); group membership is managed by creating and destroying these join rows. +- `Mv.Membership.Member` (extended) — `has_many :member_groups`, `many_to_many :groups`. Group membership is managed through the `MemberGroup` join resource, not via dedicated Member actions. -## Table of Contents +## Data Model -1. [Architecture Principles](#architecture-principles) -2. [Domain Structure](#domain-structure) -3. [Data Architecture](#data-architecture) -4. [Business Logic Architecture](#business-logic-architecture) -5. [UI/UX Architecture](#uiux-architecture) -6. [Integration Points](#integration-points) -7. [Authorization](#authorization) -8. [Performance Considerations](#performance-considerations) -9. [Future Extensibility](#future-extensibility) -10. [Implementation Phases](#implementation-phases) +### `groups` +- `id` (UUIDv7), `name` (required), `slug` (required, immutable, auto-generated), `description` (optional), timestamps. +- Uniqueness: `name` unique case-insensitively (`UNIQUE` on `LOWER(name)`, index `groups_unique_name_lower_index`); `slug` unique case-sensitively (`groups_unique_slug_index`). ---- +### `member_groups` (join table) +- `id` (UUIDv7), `member_id`, `group_id`, timestamps. +- Unique `(member_id, group_id)` prevents duplicates; indexes on `member_id` and `group_id`. +- **CASCADE delete on both foreign keys** — the cascade is intentionally on the join table only. -## Architecture Principles +For exact columns/indexes see `database_schema.dbml`. -### Core Design Decisions +## Search Integration -1. **Many-to-Many Relationship:** - - Members can belong to multiple groups - - Groups can contain multiple members - - Implemented via join table (`member_groups`) as separate Ash resource +Group names are part of the member full-text search: -2. **Flat Structure (MVP):** - - Groups are initially flat (no hierarchy) - - Architecture designed to allow hierarchical extension later - - No parent/child relationships in MVP +- They are aggregated from `member_groups` joined to `groups` and added to `members.search_vector` at **weight B**. +- The trigger `update_member_search_vector_on_member_groups_change` runs `update_member_search_vector_from_member_groups()` on **INSERT/UPDATE/DELETE on `member_groups`** and refreshes the affected member's `search_vector`. +- Migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375). No Elixir search change is needed — searching a group name finds its members automatically. -3. **Minimal Attributes (MVP):** - - `name`, `description`, and `slug` in initial version - - `slug` is automatically generated from `name` (immutable, URL-friendly) - - Extensible for future attributes (dates, status, etc.) +## UI Surface (implemented) -4. **Cascade Deletion:** - - Deleting a group removes all member-group associations - - Members themselves are not deleted (CASCADE on join table only) - - Requires explicit confirmation with group name input +- **`/groups`** — index table (name, description, member count, actions), sorted by name at the DB level. Create button → `/groups/new`. +- **`/groups/:slug`** — detail: group info, member list, inline add-member combobox (search/autocomplete, excludes members already in the group), per-row remove (no confirmation), edit/delete. Add/remove are guarded by `:update` permission both in the UI and server-side in the event handlers. +- **`/groups/:slug/edit`** and **`/groups/new`** — separate form pages; slug not editable. Edit does auth in `mount/3` and loads the group once in `handle_params/3`. +- **Delete confirmation modal** — warns with member count (pluralized), requires typing the group name to enable delete (`phx-debounce="200"`), stays open on mismatch, authorizes server-side. +- **Member overview** — "Groups" column with badges; filter dropdown (persisted in URL query params); sort by group; group names searchable. +- **Member detail** — Groups shown as a data field in Personal Data (below Linked User), button-style links to `/groups/:slug`. -5. **Search Integration:** - - Groups searchable within member search (not separate search) - - Group names included in member search vector for full-text search +## Accessibility ---- - -## Domain Structure - -### Ash Domain: `Mv.Membership` - -**Purpose:** Groups are part of the Membership domain, alongside Members and CustomFields - -**New Resources:** - -- `Group` - Group definitions (name, description, slug) -- `MemberGroup` - Join table for many-to-many relationship between Members and Groups - -**Extended Resources:** - -- `Member` - Extended with `has_many :groups` relationship (through MemberGroup) - -### Module Organization - -``` -lib/ -├── membership/ -│ ├── membership.ex # Domain definition (extended) -│ ├── group.ex # Group resource -│ ├── member_group.ex # MemberGroup join table resource -│ └── member.ex # Extended with groups relationship -├── mv_web/ -│ └── live/ -│ ├── group_live/ -│ │ ├── index.ex # Groups management page -│ │ ├── form.ex # Create/edit group form -│ │ └── show.ex # Group detail view -│ └── member_live/ -│ ├── index.ex # Extended with group filtering/sorting -│ └── show.ex # Extended with group display -└── mv/ - └── membership/ - └── group/ # Future: Group-specific business logic - └── helpers.ex # Group-related helper functions -``` - ---- - -## Data Architecture - -### Database Schema - -#### `groups` Table - -**Attributes:** -- `id` - UUID v7 primary key -- `name` - Unique group name (required, max 100 chars) -- `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name) -- `description` - Optional description (max 500 chars) -- `inserted_at` / `updated_at` - Timestamps - -**Constraints:** -- `name` must be unique (case-insensitive, using LOWER(name)) -- `slug` must be unique (case-sensitive, exact match) -- `name` cannot be null -- `slug` cannot be null -- `name` max length: 100 characters -- `slug` max length: 100 characters -- `description` max length: 500 characters - -#### `member_groups` Table (Join Table) - -**Attributes:** -- `id` - UUID v7 primary key -- `member_id` - Foreign key to members (CASCADE delete) -- `group_id` - Foreign key to groups (CASCADE delete) -- `inserted_at` / `updated_at` - Timestamps - -**Constraints:** -- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships -- CASCADE delete: Removing member removes all group associations -- CASCADE delete: Removing group removes all member associations - -**Indexes:** -- Index on `member_id` for efficient member → groups queries -- Index on `group_id` for efficient group → members queries - -### Ash Resources - -#### `Mv.Membership.Group` - -**Relationships:** -- `has_many :member_groups` - Relationship to MemberGroup join table -- `many_to_many :members` - Relationship to Members through MemberGroup - -**Calculations:** -- `member_count` - Integer calculation counting associated members - -**Actions:** -- `create` - Create new group (auto-generates slug from name) -- `read` - List/search groups (can query by slug via identity) -- `update` - Update group name/description (slug remains unchanged) -- `destroy` - Delete group (with confirmation) - -**Validations:** -- `name` required, unique (case-insensitive), max 100 chars -- `slug` required, unique (case-sensitive), max 100 chars, auto-generated, immutable -- `description` optional, max 500 chars - -**Identities:** -- `unique_slug` - Unique identity on `slug` for efficient lookups - -#### `Mv.Membership.MemberGroup` - -**Relationships:** -- `belongs_to :member` - Relationship to Member -- `belongs_to :group` - Relationship to Group - -**Actions:** -- `create` - Add member to group -- `read` - Query member-group associations -- `destroy` - Remove member from group - -**Validations:** -- Unique constraint on `(member_id, group_id)` - -#### `Mv.Membership.Member` (Extended) - -**New Relationships:** -- `has_many :member_groups` - Relationship to MemberGroup join table -- `many_to_many :groups` - Relationship to Groups through MemberGroup - -**New Actions:** -- `add_to_groups` - Add member to one or more groups -- `remove_from_groups` - Remove member from one or more groups - ---- - -## Business Logic Architecture - -### Group Management - -**Create Group:** -- Validate name uniqueness -- Automatically generate slug from name (using `GenerateSlug` change, same pattern as CustomFields) -- Validate slug uniqueness -- Return created group - -**Update Group:** -- Validate name uniqueness (if name changed) -- Update description -- Slug remains unchanged (immutable after creation) -- Return updated group - -**Delete Group:** -- Check if group has members (for warning display) -- Require explicit confirmation (group name input) -- Cascade delete all `member_groups` associations -- Group itself deleted - -### Member-Group Association - -**Add Member to Group:** -- Validate member exists -- Validate group exists -- Check for duplicate association -- Create `MemberGroup` record - -**Remove Member from Group:** -- Find `MemberGroup` record -- Delete association -- Member and group remain intact - -**Bulk Operations:** -- Add member to multiple groups in single transaction -- Remove member from multiple groups in single transaction - -### Search Integration - -**Member Search Enhancement:** -- Include group names in member search vector -- When searching for member, also search in associated group names -- Example: Searching for a group name finds all members in groups with that name - -**Implementation:** -- Extend `member.search_vector` trigger to include group names -- Update trigger on `member_groups` changes -- Use PostgreSQL `tsvector` for full-text search - ---- - -## UI/UX Architecture - -### Groups Management Page (`/groups`) - -**Route:** `/groups` - Groups management index page - -**Features:** -- List all groups in table (sorted by name via database query) -- Create new group button (navigates to `/groups/new`) -- Edit group via separate form page (`/groups/:slug/edit`) -- Delete group with confirmation modal -- Show member count per group - -**Table Columns:** -- Name (sortable, searchable) -- Description -- Member Count -- Actions (Edit, Delete) - -**Delete Confirmation Modal:** -- Warning: "X members are in this group" (with proper pluralization) -- Confirmation: "All member-group associations will be permanently deleted" -- Input field: Enter group name to confirm (with `phx-debounce="200"` for better UX) -- Delete button disabled until name matches -- Modal remains open on name mismatch (allows user to correct input) -- Cancel button -- Server-side authorization check in delete event handler (security best practice) - -### Member Overview Integration - -**New Column: "Groups"** -- Display group badges for each member -- Badge shows group name -- Multiple badges if member in multiple groups -- *(Optional)* Click badge to filter by that group (enhanced UX, can be added later) - -**Filtering:** -- Dropdown/select to filter by group -- "All groups" option (default) -- Filter persists in URL query params -- Works with existing search/sort - -**Sorting:** -- Sort by group name (members with groups first, then alphabetically) -- Sort by number of groups (members with most groups first) - -**Search:** -- Group names included in member search -- Searching group name shows all members in that group - -### Member Detail View Integration - -**New Section: "Groups"** -- List all groups member belongs to -- Display as badges or list -- Add/remove groups inline -- Link to group detail page - -### Group Detail View (`/groups/:slug`) - -**Route:** `/groups/:slug` - Group detail page (uses slug for URL-friendly routing) - -**Features:** -- Display group name and description -- List all members in group -- Link to member detail pages -- Add members to group (via inline combobox with search/autocomplete) -- Remove members from group (via remove button per member) -- Edit group button (navigates to `/groups/:slug/edit`) -- Delete group button (with confirmation modal) - -**Add Member Functionality:** -- "Add Member" button displayed above member table (only for users with `:update` permission) -- Opens inline add member area with member search/autocomplete (combobox) -- Search filters out members already in the group -- Selecting a member adds them to the group immediately -- Success/error flash messages provide feedback -- "Cancel" button closes the inline add member area without adding - -**Remove Member Functionality:** -- "Remove" button (icon button) for each member in table (only for users with `:update` permission) -- Clicking remove immediately removes member from group (no confirmation dialog) -- Success/error flash messages provide feedback - -**Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`). - -### Group Form Pages - -**Create Form:** `/groups/new` -- Separate LiveView page for creating new groups -- Form with name and description fields -- Slug is auto-generated and not editable -- Redirects to `/groups` on success - -**Edit Form:** `/groups/:slug/edit` -- Separate LiveView page for editing existing groups -- Form pre-populated with current group data -- Slug is immutable (not displayed in form) -- Redirects to `/groups/:slug` on success -- `mount/3` performs authorization check, `handle_params/3` loads group once - -### Accessibility (A11y) Considerations - -**Requirements:** -- All UI elements must be keyboard accessible -- Screen readers must be able to navigate and understand the interface -- ARIA labels and roles must be properly set - -**Group Badges and Links in Member Overview / Detail:** -- Use `aria-label` to indicate group membership (e.g. "Member of group X"). Do not use `role="status"` on badges or links—that role is for live regions (screen reader announcements), not for navigation or static labels. -- Badge/link text or title should indicate group membership for screen readers. - -**Clickable Group Badge (for filtering) - Optional:** - -**Note:** This is an optional enhancement. The dropdown filter provides the same functionality. The clickable badge improves UX by showing the active filter visually and allowing quick removal. - -**Estimated effort:** 1.5-2.5 hours - -- Clickable badges must be proper button elements with `type="button"` -- Must include `aria-label` describing the filter action -- Icon for removal should have `aria-hidden="true"` - -**Group Filter Dropdown:** -- Select element must have appropriate `id`, `name`, and `aria-label` attributes -- Options should clearly indicate selected state - -**Screen Reader Announcements:** -- Use `role="status"` with `aria-live="polite"` for dynamic content -- Announce filter changes and member count updates - -**Delete Confirmation Modal:** -- Modal must use proper `role="dialog"` with `aria-labelledby` and `aria-describedby` -- Warning messages must be clearly associated with the modal description -- Form inputs must be properly labeled - -**Keyboard Navigation:** -- All interactive elements (buttons, links, form inputs) must be focusable via Tab key -- Modal dialogs must trap focus (Tab key cycles within modal) -- Escape key closes modals -- Enter/Space activates buttons when focused - ---- - -## Integration Points - -### Member Search Vector - -**Trigger Update:** -- When `member_groups` record created/deleted -- Update `members.search_vector` to include group names -- Use PostgreSQL trigger for automatic updates - -**Search Query:** -- Extend existing `fuzzy_search` to include group names -- Group names added with weight 'B' (same as city, etc.) - -### Member Form - -**Future Enhancement:** -- Add groups selection in member form -- Multi-select dropdown for groups -- Add/remove groups during member creation/edit - -### Authorization Integration - -**Current (MVP):** -- Only admins can manage groups -- Uses existing `Mv.Authorization.Checks.HasPermission` -- Permission: `groups` resource with `:all` scope - -**Future:** -- Group-specific permissions -- Role-based group management -- Member-level group assignment permissions - ---- +- **Do not use `role="status"` on group badges or navigation links.** That role is for live regions (screen-reader announcements), not for static labels or navigation. Use `aria-label` (e.g. "Member of group X") instead. +- `role="status"` with `aria-live="polite"` is appropriate only for dynamic announcements (filter changes, member-count updates). +- Clickable filter badges (optional enhancement) must be real `