From 419b64270cecbbe08095192ef5c90728adca744f Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 7 Mar 2026 00:04:57 +0000 Subject: [PATCH 1/8] Update renovate/renovate Docker tag to v43 --- .drone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 70ea161..5442fe7 100644 --- a/.drone.yml +++ b/.drone.yml @@ -273,7 +273,7 @@ environment: steps: - name: renovate - image: renovate/renovate:42.99 + image: renovate/renovate:43.59 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: From 44694218715f5b0ecb4ac6b07e9d55be6c0a5650 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 13:14:38 +0100 Subject: [PATCH 2/8] fix renovate syntax --- renovate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index f134b7b..110894d 100644 --- a/renovate.json +++ b/renovate.json @@ -5,7 +5,7 @@ "packageRules": [ { "groupName": "Mix dependencies", - "matchCategories": "elixir" + "matchCategories": ["elixir"] }, { "groupName": "asdf tool versions", From bda2aba06d24c09123eee6e1b79cba52ed9a860e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 8 Mar 2026 00:04:16 +0000 Subject: [PATCH 3/8] Update Mix dependencies --- mix.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/mix.lock b/mix.lock index d9ab997..42ee3e1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,11 @@ %{ - "ash": {:hex, :ash, "3.19.1", "b5e933547d948e44d27adaed5737195488292fc2066e7fe60dd3ac83a0c4e54f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.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.3.14 and < 3.0.0-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", "697ac3e4fc6080cb03b1e4ee9088cb8a313a5299686ba1aa91efc86ec4028b6e"}, + "ash": {:hex, :ash, "3.19.3", "58b1bb3aea3d1d45d1c990059ffd0753409cc92fc4afe387376cb155e2a8c2a0", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.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.3.14 and < 3.0.0-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", "94b628319f2e144affaf1f8008277bad3340a198d48e6d2ed372990ac1643f9b"}, "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_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.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [: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", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [: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", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"}, - "ash_postgres": {:hex, :ash_postgres, "2.6.32", "4bdb281bdffd69c08337396d00ffa0ee429a83b5ac3c843e3982ecfb0aae342b", [:mix], [{:ash, "~> 3.15", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "d1df73f9425bd8fbff325a21e06b4ae64a1eebdec38ed524121f2ebbbd62c971"}, - "ash_sql": {:hex, :ash_sql, "0.4.5", "30030675ce995570fcedccd3c0671d85beff03cc0c480e7da5002842dccf0277", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "131e06e13ebcf06fc8d050267a5b29f6cc8ef6a781712e61a456f17726a64ea5"}, + "ash_postgres": {:hex, :ash_postgres, "2.7.0", "c15e9d1e2f7a941fdfbfa9d674bde7e5db33be015c304a499f790d933b8b8278", [:mix], [{:ash, "~> 3.19", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4a83d7422987c467aaf2bbc5daff8616b3378f42cd0b2b6744a700559671e72e"}, + "ash_sql": {:hex, :ash_sql, "0.5.0", "06cf976f2cca3c16542b9c3103220ed1358903c6a83bd0ee541432d371a87a9e", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "b78865699cd706db7d8e07f366a5bd61ea06dde19f649870ff895d293ecc42a1"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, @@ -19,14 +19,14 @@ "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [: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", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "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.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, - "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [: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", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, "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"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.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", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"}, - "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "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"}, @@ -40,7 +40,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.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [: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", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"}, + "igniter": {:hex, :igniter, "0.7.3", "33dccf5f9e3def0358bf01735cfa26c6c25b307078b728e5e514f5df258fbc59", [: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", "9c6144bb50202329cbcbe941c06aed87d7ddc212ce4ecf855f32ab0bbf175258"}, "imprintor": {:hex, :imprintor, "0.5.0", "3266aa8487cc6eab3915a578c79d49e489d1bacf959a6535b1ef32acc62d71cc", [: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", "d4bbfbd26c2ddbb7eb38894b7412c0ef62f953cbb176df3cccbd266fe890c12f"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -57,13 +57,13 @@ "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.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [: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", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_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.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [: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", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.26", "306af67d6557cc01f880107cc459f1fa0acbaab60bc8c027a368ba16b3544473", [: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", "0ec34b24c69aa70c4f25a8901effe3462bee6c8ca80a9a4a7685215e3a0ac34e"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, @@ -75,19 +75,19 @@ "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [: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", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"}, "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"}, - "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [: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", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, + "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.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "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.11.0", "df2cdaffdc323e804009ff50b50bb31e6f2d6e116d936ccf22981f592594d624", [:mix], [], "hexpm", "6e26f572bdfc21d7ad397f596b4cfbbf31d7112126fe3e902c120947073231a8"}, + "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.4.1", "d6807291e74b51f6efb6dd4e0d58216ae3729d45c35c456e049556e7e946e364", [: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", "8b065733de9840cac584515f82182ac5ba66a973a47bc5036348dc740662b46b"}, - "spitfire": {:hex, :spitfire, "0.3.7", "d6051f94f554d33d038ab3c1d7e017293ae30429cc6b267b08cb6ad69e35e9a3", [], [], "hexpm", "798ff97db02477b05fa3db8e2810cebda6ed5d90c6de6b21aa65abd577599744"}, + "spitfire": {:hex, :spitfire, "0.3.10", "19aea9914132456515e8f7d592f63ab9f3130876b0252e834d2390bdd8becb24", [:mix], [], "hexpm", "6a6a5f77eb4165249c76199cd2d01fb595bac9207aed3de551918ac1c2bc9267"}, "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, - "swoosh": {:hex, :swoosh, "1.22.1", "8450ac62d0a7cb82f0765592037cab2d30cbc7801acd879f77b8f672a9b49f58", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13795cd69e137c7a6b99850b938177fa3713bd6b95e92b3bdcdb29b70e88868e"}, + "swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry": {:hex, :telemetry, "1.4.0", "69aa83d53f152f93f05fd40b77ded6fab247de093b7a3c4ca2879e634144446e", [:rebar3], [], "hexpm", "d1ff426f988ac1092f9d684d34d08e51042a70567c16be793fbc8f399fd2e77d"}, "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"}, From a8f12d1c918aa786d98b7a1e36b7935eeab7771c Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Mar 2026 20:46:31 +0100 Subject: [PATCH 4/8] Add member fee type filter to member list - Filter by membership fee type in same style as groups (All/Yes/No per type) - Index: load fee types, fee_type_filters, URL params, apply_fee_type_filters - MemberFilterComponent: fee types section, events, reset, button label - Refactor update_filters: extract parse/dispatch helpers to satisfy Credo complexity --- .../components/member_filter_component.ex | 269 ++++++++++++++---- lib/mv_web/live/member_live/index.ex | 197 ++++++++++++- lib/mv_web/live/member_live/index.html.heex | 2 + 3 files changed, 399 insertions(+), 69 deletions(-) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 4a42bbc..56c5666 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -19,6 +19,8 @@ defmodule MvWeb.Components.MemberFilterComponent do - `:groups` - List of groups (for per-group filter rows) - `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group). Multiple active filters combine with AND (member must match all selected group conditions). + - `:fee_types` - List of membership fee types (for per-fee-type filter rows) + - `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All). - `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:id` - Component ID (required) @@ -27,11 +29,13 @@ defmodule MvWeb.Components.MemberFilterComponent do ## Events - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) + - Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in) - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes """ use MvWeb, :live_component @group_filter_prefix "group_" + @fee_type_filter_prefix "fee_type_" @impl true def mount(socket) do @@ -47,6 +51,9 @@ defmodule MvWeb.Components.MemberFilterComponent do |> assign(:groups, assigns[:groups] || []) |> assign(:group_filters, assigns[:group_filters] || %{}) |> assign(:group_filter_prefix, @group_filter_prefix) + |> assign(:fee_types, assigns[:fee_types] || []) + |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) + |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) |> assign(:member_count, assigns[:member_count] || 0) @@ -71,6 +78,7 @@ defmodule MvWeb.Components.MemberFilterComponent do class={[ "gap-2", (@cycle_status_filter || map_size(@group_filters) > 0 || + map_size(@fee_type_filters) > 0 || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active" ]} @@ -86,6 +94,8 @@ defmodule MvWeb.Components.MemberFilterComponent do @cycle_status_filter, @groups, @group_filters, + @fee_types, + @fee_type_filters, @boolean_custom_fields, @boolean_filters )} @@ -99,7 +109,7 @@ defmodule MvWeb.Components.MemberFilterComponent do <.badge :if={ - (@cycle_status_filter || map_size(@group_filters) > 0) && + (@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) && active_boolean_filters_count(@boolean_filters) == 0 } variant="primary" @@ -250,6 +260,73 @@ defmodule MvWeb.Components.MemberFilterComponent do + +
0} class="mb-4"> +
+ {gettext("Fee types")} +
+
+
+ + {fee_type.name} + +
+ + + +
+
+
+
+
0} class="mb-2">
@@ -356,69 +433,21 @@ defmodule MvWeb.Components.MemberFilterComponent do @impl true def handle_event("update_filters", params, socket) do - # Parse payment filter - payment_filter = - case Map.get(params, "payment_filter") do - "paid" -> :paid - "unpaid" -> :unpaid - _ -> nil - end - - # Parse per-group filters (params keys "group_" => "all"|"in"|"not_in") - prefix_len = String.length(@group_filter_prefix) + payment_filter = parse_payment_filter(params) group_filters_parsed = - params - |> Enum.filter(fn {key, _} -> String.starts_with?(key, @group_filter_prefix) end) - |> Enum.reduce(%{}, fn {key, value_str}, acc -> - group_id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) - filter_value = parse_group_filter_value(value_str) - Map.put(acc, group_id_str, filter_value) - end) + parse_prefix_filters(params, @group_filter_prefix, &parse_group_filter_value/1) - # Parse boolean custom field filters (including nil values for "all") - custom_boolean_filters_parsed = - params - |> Map.get("custom_boolean", %{}) - |> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc -> - filter_value = parse_tri_state(value_str) - Map.put(acc, custom_field_id_str, filter_value) - end) + fee_type_filters_parsed = + parse_prefix_filters(params, @fee_type_filter_prefix, &parse_fee_type_filter_value/1) - # Update payment filter if changed - if payment_filter != socket.assigns.cycle_status_filter do - send(self(), {:payment_filter_changed, payment_filter}) - end + custom_boolean_filters_parsed = parse_custom_boolean_filters(params) - # Update group filters - send event for each changed group - current_group_filters = socket.assigns.group_filters - all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) + dispatch_payment_filter_change(socket, payment_filter) + dispatch_group_filter_changes(socket, group_filters_parsed) + dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) + dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) - Enum.each(group_filters_parsed, fn {group_id_str, new_value} -> - in_set = MapSet.member?(all_group_ids, group_id_str) - current_value = Map.get(current_group_filters, group_id_str) - should_send = in_set and current_value != new_value - - if should_send do - send(self(), {:group_filter_changed, group_id_str, new_value}) - end - end) - - # Update boolean filters - send events for each changed filter - current_filters = socket.assigns.boolean_filters - - # Process all custom field filters from form (including those set to "all"/nil) - # Radio buttons in a group always send a value, so all active filters are in the form - Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} -> - current_value = Map.get(current_filters, custom_field_id_str) - - # Only send event if value actually changed - if current_value != new_value do - send(self(), {:boolean_filter_changed, custom_field_id_str, new_value}) - end - end) - - # Don't close dropdown - allow multiple filter changes {:noreply, socket} end @@ -426,7 +455,7 @@ defmodule MvWeb.Components.MemberFilterComponent do def handle_event("reset_filters", _params, socket) do # Send single message to reset all filters at once (performance optimization) # This avoids N×2 load_members() calls when resetting multiple filters - send(self(), {:reset_all_filters, nil, %{}, %{}}) + send(self(), {:reset_all_filters, nil, %{}, %{}, %{}}) # Close dropdown after reset {:noreply, assign(socket, :open, false)} @@ -442,11 +471,82 @@ defmodule MvWeb.Components.MemberFilterComponent do defp parse_group_filter_value("not_in"), do: :not_in defp parse_group_filter_value(_), do: nil + defp parse_fee_type_filter_value("in"), do: :in + defp parse_fee_type_filter_value("not_in"), do: :not_in + defp parse_fee_type_filter_value(_), do: nil + + defp parse_payment_filter(params) do + case Map.get(params, "payment_filter") do + "paid" -> :paid + "unpaid" -> :unpaid + _ -> nil + end + end + + defp parse_prefix_filters(params, prefix, parse_value_fn) do + prefix_len = String.length(prefix) + + params + |> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end) + |> Enum.reduce(%{}, fn {key, value_str}, acc -> + id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) + Map.put(acc, id_str, parse_value_fn.(value_str)) + end) + end + + defp parse_custom_boolean_filters(params) do + params + |> Map.get("custom_boolean", %{}) + |> Enum.reduce(%{}, fn {id_str, value_str}, acc -> + Map.put(acc, id_str, parse_tri_state(value_str)) + end) + end + + defp dispatch_payment_filter_change(socket, payment_filter) do + if payment_filter != socket.assigns.cycle_status_filter do + send(self(), {:payment_filter_changed, payment_filter}) + end + end + + defp dispatch_group_filter_changes(socket, group_filters_parsed) do + current = socket.assigns.group_filters + valid_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) + + Enum.each(group_filters_parsed, fn {id_str, new_value} -> + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do + send(self(), {:group_filter_changed, id_str, new_value}) + end + end) + end + + defp dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) do + current = socket.assigns.fee_type_filters + valid_ids = MapSet.new(Enum.map(socket.assigns.fee_types, &to_string(&1.id))) + + Enum.each(fee_type_filters_parsed, fn {id_str, new_value} -> + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do + send(self(), {:fee_type_filter_changed, id_str, new_value}) + end + end) + end + + defp dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) do + current = socket.assigns.boolean_filters + + Enum.each(custom_boolean_filters_parsed, fn {id_str, new_value} -> + if Map.get(current, id_str) != new_value do + send(self(), {:boolean_filter_changed, id_str, new_value}) + end + end) + end + # Get display label for button defp button_label( cycle_status_filter, groups, group_filters, + fee_types, + fee_type_filters, boolean_custom_fields, boolean_filters ) do @@ -457,6 +557,9 @@ defmodule MvWeb.Components.MemberFilterComponent do map_size(group_filters) > 0 -> group_filters_label(groups, group_filters) + map_size(fee_type_filters) > 0 -> + fee_type_filters_label(fee_types, fee_type_filters) + map_size(boolean_filters) > 0 -> boolean_filter_label(boolean_custom_fields, boolean_filters) @@ -480,6 +583,21 @@ defmodule MvWeb.Components.MemberFilterComponent do truncate_label(label, 30) end + defp fee_type_filters_label(_fee_types, fee_type_filters) when map_size(fee_type_filters) == 0, + do: gettext("All") + + defp fee_type_filters_label(fee_types, fee_type_filters) do + fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) + + names = + fee_type_filters + |> Enum.map(fn {fee_type_id_str, _} -> Map.get(fee_types_by_id, fee_type_id_str) end) + |> Enum.reject(&is_nil/1) + + label = Enum.join(names, ", ") + truncate_label(label, 30) + end + # Get payment filter label defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(:paid), do: gettext("Paid") @@ -586,6 +704,39 @@ defmodule MvWeb.Components.MemberFilterComponent do end end + # Get CSS classes for per-fee-type filter label based on current state + defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do + base_classes = "join-item btn btn-sm" + current_value = Map.get(fee_type_filters, to_string(fee_type_id)) + is_active = current_value == expected_value + + cond do + expected_value == nil -> + if is_active do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + + expected_value == :in -> + if is_active do + "#{base_classes} btn-success btn-active" + else + "#{base_classes} btn" + end + + expected_value == :not_in -> + if is_active do + "#{base_classes} btn-error btn-active" + else + "#{base_classes} btn" + end + + true -> + "#{base_classes} btn-outline" + end + end + # Get CSS classes for boolean filter label based on current state defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do base_classes = "join-item btn btn-sm" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6cf532d..c745a3d 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -33,6 +33,8 @@ defmodule MvWeb.MemberLive.Index do alias Mv.Membership alias Mv.Membership.Member, as: MemberResource + alias Mv.MembershipFees + alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility @@ -42,6 +44,7 @@ defmodule MvWeb.MemberLive.Index do @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() @group_filter_prefix "group_" + @fee_type_filter_prefix "fee_type_" # Maximum number of boolean custom field filters allowed per request (DoS protection) @max_boolean_filters Mv.Constants.max_boolean_filters() @@ -89,6 +92,12 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) + # Load membership fee types for filter dropdown (sorted by name) + fee_types = + MembershipFeeType + |> Ash.Query.sort(name: :asc) + |> Ash.read!(domain: MembershipFees, actor: actor) + # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do @@ -121,6 +130,8 @@ defmodule MvWeb.MemberLive.Index do |> assign(:cycle_status_filter, nil) |> assign(:group_filters, %{}) |> assign(:groups, groups) + |> assign(:fee_type_filters, %{}) + |> assign(:fee_types, fee_types) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) |> assign(:selected_member_id, nil) @@ -218,7 +229,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], new_show_current, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -300,7 +312,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -339,7 +352,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -367,7 +381,8 @@ defmodule MvWeb.MemberLive.Index do filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -401,7 +416,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - updated_filters + updated_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -437,7 +453,45 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, group_filters, socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_patch(socket, to: new_path, replace: true)} + end + + @impl true + def handle_info({:fee_type_filter_changed, fee_type_id_str, filter_value}, socket) do + normalized_id = normalize_uuid_string(fee_type_id_str) || fee_type_id_str + + fee_type_filters = + if filter_value == nil do + Map.delete(socket.assigns.fee_type_filters, normalized_id) + else + Map.put(socket.assigns.fee_type_filters, normalized_id, filter_value) + end + + socket = + socket + |> assign(:fee_type_filters, fee_type_filters) + |> load_members() + |> update_selection_assigns() + + query_params = + build_query_params( + socket.assigns.query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters, + fee_type_filters ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -450,17 +504,29 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do - handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket) + handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}, %{}}, socket) end def handle_info( {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters}, socket ) do + handle_info( + {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, %{}}, + socket + ) + end + + def handle_info( + {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, + fee_type_filters}, + socket + ) do socket = socket |> assign(:cycle_status_filter, cycle_status_filter) |> assign(:group_filters, group_filters) + |> assign(:fee_type_filters, fee_type_filters) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() @@ -473,7 +539,8 @@ defmodule MvWeb.MemberLive.Index do cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - boolean_filters + boolean_filters, + fee_type_filters ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -598,6 +665,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_sort(params) |> maybe_update_cycle_status_filter(params) |> maybe_update_group_filters(params) + |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) @@ -646,6 +714,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_order, socket.assigns.cycle_status_filter, socket.assigns[:group_filters], + socket.assigns[:fee_type_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, @@ -739,7 +808,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.boolean_custom_field_filters, + socket.assigns[:fee_type_filters] ) |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) @@ -758,15 +828,24 @@ defmodule MvWeb.MemberLive.Index do cycle_status_filter, group_filters, show_current_cycle, - boolean_filters + boolean_filters, + fee_type_filters ) do base_params = build_base_params(query, sort_field, sort_order) base_params = add_cycle_status_filter(base_params, cycle_status_filter) base_params = add_group_filters(base_params, group_filters) + base_params = add_fee_type_filters(base_params, fee_type_filters || %{}) base_params = add_show_current_cycle(base_params, show_current_cycle) add_boolean_filters(base_params, boolean_filters) end + defp add_fee_type_filters(params, fee_type_filters) do + Enum.reduce(fee_type_filters, params, fn {fee_type_id_str, value}, acc -> + param_value = if value == :in, do: "in", else: "not_in" + Map.put(acc, "#{@fee_type_filter_prefix}#{fee_type_id_str}", param_value) + end) + end + defp compute_final_field_selection(true, url_selection, socket) do only_url = FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) @@ -941,6 +1020,9 @@ defmodule MvWeb.MemberLive.Index do query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) + query = + apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields @@ -1064,6 +1146,55 @@ defmodule MvWeb.MemberLive.Index do defp apply_one_group_filter(query, _, _), do: query + # Multiple fee type filters combine with AND: member must match all selected fee type conditions. + defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, + do: query + + defp apply_fee_type_filters(query, fee_type_filters, fee_types) do + valid_ids = + fee_types + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + Enum.reduce(fee_type_filters, query, fn {fee_type_id_str, value}, q -> + member? = MapSet.member?(valid_ids, fee_type_id_str) + + if member? do + apply_one_fee_type_filter(q, fee_type_id_str, value) + else + q + end + end) + end + + defp apply_one_fee_type_filter(query, _fee_type_id_str, nil), do: query + + defp apply_one_fee_type_filter(query, fee_type_id_str, :in) do + case Ecto.UUID.cast(fee_type_id_str) do + {:ok, fee_type_uuid} -> + Ash.Query.filter(query, expr(membership_fee_type_id == ^fee_type_uuid)) + + _ -> + query + end + end + + defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do + case Ecto.UUID.cast(fee_type_id_str) do + {:ok, fee_type_uuid} -> + Ash.Query.filter( + query, + expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id)) + ) + + _ -> + query + end + end + + defp apply_one_fee_type_filter(query, _, _), do: query + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) @@ -1397,6 +1528,52 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_group_filters(socket, _), do: socket + defp maybe_update_fee_type_filters(socket, params) when is_map(params) do + prefix = @fee_type_filter_prefix + prefix_len = String.length(prefix) + + fee_type_param_entries = + params + |> Enum.filter(fn {key, _} -> + key_str = to_string(key) + String.starts_with?(key_str, prefix) + end) + + filters = + Enum.reduce(fee_type_param_entries, %{}, fn {key, value_str}, acc -> + add_fee_type_filter_entry(acc, key, value_str, prefix_len) + end) + + assign(socket, :fee_type_filters, filters) + end + + defp maybe_update_fee_type_filters(socket, _), do: socket + + defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do + key_str = to_string(key) + raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) + fee_type_id_str = normalize_uuid_string(raw_id) + valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length + + if valid_id? do + case parse_fee_type_filter_value(value_str) do + nil -> acc + value -> Map.put(acc, fee_type_id_str, value) + end + else + acc + end + end + + defp parse_fee_type_filter_value("in"), do: :in + defp parse_fee_type_filter_value("not_in"), do: :not_in + + defp parse_fee_type_filter_value(val) when is_binary(val) do + parse_fee_type_filter_value(String.trim(val)) + end + + defp parse_fee_type_filter_value(_), do: nil + defp add_group_filter_entry(acc, key, value_str, prefix_len) do key_str = to_string(key) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 84167c4..b35d426 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -50,6 +50,8 @@ cycle_status_filter={@cycle_status_filter} groups={@groups} group_filters={@group_filters} + fee_types={@fee_types} + fee_type_filters={@fee_type_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} From 3af52f2829f5b49ddc564442f23ae1303f6fe4ee Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Mar 2026 20:46:42 +0100 Subject: [PATCH 5/8] Update gettext: extract and merge after fee type filter strings --- priv/gettext/de/LC_MESSAGES/default.po | 5 +++++ priv/gettext/default.pot | 5 +++++ priv/gettext/en/LC_MESSAGES/default.po | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 982798d..0f07068 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3232,3 +3232,8 @@ msgstr "Standardart: Wird neuen Mitgliedern zugewiesen; pro Mitglied änderbar." #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee types" +msgstr "Beitragsart" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5b6ef4c..3ca889b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3232,3 +3232,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Fee types" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a566be0..6c6069a 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3232,3 +3232,8 @@ msgstr "Default type: Assigned to new members; can be changed per member." #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee types" +msgstr "" From ae07e3efc2c8330ac3a8ad4264c8957f22b8f446 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 14:28:31 +0100 Subject: [PATCH 6/8] Add filter prefix constants and shared FilterParams module - Mv.Constants: group_filter_prefix/0, fee_type_filter_prefix/0 - MvWeb.MemberLive.Index.FilterParams: parse_in_not_in_value/1 for URL param parsing --- lib/mv/constants.ex | 14 ++++++++++++ .../live/member_live/index/filter_params.ex | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/filter_params.ex diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 7bb6274..517ad2f 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -22,6 +22,10 @@ defmodule Mv.Constants do @boolean_filter_prefix "bf_" + @group_filter_prefix "group_" + + @fee_type_filter_prefix "fee_type_" + @max_boolean_filters 50 @max_uuid_length 36 @@ -70,6 +74,16 @@ defmodule Mv.Constants do """ def boolean_filter_prefix, do: @boolean_filter_prefix + @doc """ + Returns the prefix for group filter URL parameters (e.g. group_=in|not_in). + """ + def group_filter_prefix, do: @group_filter_prefix + + @doc """ + Returns the prefix for fee type filter URL parameters (e.g. fee_type_=in|not_in). + """ + def fee_type_filter_prefix, do: @fee_type_filter_prefix + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/member_live/index/filter_params.ex b/lib/mv_web/live/member_live/index/filter_params.ex new file mode 100644 index 0000000..9b5e800 --- /dev/null +++ b/lib/mv_web/live/member_live/index/filter_params.ex @@ -0,0 +1,22 @@ +defmodule MvWeb.MemberLive.Index.FilterParams do + @moduledoc """ + Shared parsing helpers for member list filter URL/params (in/not_in style). + Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs. + """ + @doc """ + Parses a value for group or fee-type filter params. + Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. + """ + def parse_in_not_in_value("in"), do: :in + def parse_in_not_in_value("not_in"), do: :not_in + + def parse_in_not_in_value(val) when is_binary(val) do + case String.trim(val) do + "in" -> :in + "not_in" -> :not_in + _ -> nil + end + end + + def parse_in_not_in_value(_), do: nil +end From 8da22b3d8835754d77b3eb8bd998d5272b21cb17 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 14:28:50 +0100 Subject: [PATCH 7/8] Apply review feedback and fix Credo in fee type filter - Index: use FilterParams and constants; fix parse recursion; validate fee type/group IDs; OR semantics for :in; build_query_params/reset_all_filters map-based API; alias order (Credo); Map.take list deprecation fix - MemberFilterComponent: use FilterParams and constants; fee_type_filter_part helper (Credo nesting); in_not_in_filter_label_class; reset_all_filters map; button label for :not_in and combined filter count; fieldset borders - Gettext: Fee types, filter count plural, 'without %{name}' (en/de) --- .../components/member_filter_component.ex | 170 ++++++----- lib/mv_web/live/member_live/index.ex | 264 ++++++++---------- priv/gettext/de/LC_MESSAGES/default.po | 16 +- priv/gettext/default.pot | 12 + priv/gettext/en/LC_MESSAGES/default.po | 16 +- 5 files changed, 233 insertions(+), 245 deletions(-) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 56c5666..ddd3538 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -34,8 +34,10 @@ defmodule MvWeb.Components.MemberFilterComponent do """ use MvWeb, :live_component - @group_filter_prefix "group_" - @fee_type_filter_prefix "fee_type_" + alias MvWeb.MemberLive.Index.FilterParams + + @group_filter_prefix Mv.Constants.group_filter_prefix() + @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() @impl true def mount(socket) do @@ -201,7 +203,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{group.name} @@ -268,7 +270,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{fee_type.name} @@ -335,7 +337,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{custom_field.name} @@ -436,10 +438,10 @@ defmodule MvWeb.Components.MemberFilterComponent do payment_filter = parse_payment_filter(params) group_filters_parsed = - parse_prefix_filters(params, @group_filter_prefix, &parse_group_filter_value/1) + parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1) fee_type_filters_parsed = - parse_prefix_filters(params, @fee_type_filter_prefix, &parse_fee_type_filter_value/1) + parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1) custom_boolean_filters_parsed = parse_custom_boolean_filters(params) @@ -455,7 +457,16 @@ defmodule MvWeb.Components.MemberFilterComponent do def handle_event("reset_filters", _params, socket) do # Send single message to reset all filters at once (performance optimization) # This avoids N×2 load_members() calls when resetting multiple filters - send(self(), {:reset_all_filters, nil, %{}, %{}, %{}}) + send( + self(), + {:reset_all_filters, + %{ + cycle_status_filter: nil, + boolean_filters: %{}, + group_filters: %{}, + fee_type_filters: %{} + }} + ) # Close dropdown after reset {:noreply, assign(socket, :open, false)} @@ -467,14 +478,6 @@ defmodule MvWeb.Components.MemberFilterComponent do defp parse_tri_state("all"), do: nil defp parse_tri_state(_), do: nil - defp parse_group_filter_value("in"), do: :in - defp parse_group_filter_value("not_in"), do: :not_in - defp parse_group_filter_value(_), do: nil - - defp parse_fee_type_filter_value("in"), do: :in - defp parse_fee_type_filter_value("not_in"), do: :not_in - defp parse_fee_type_filter_value(_), do: nil - defp parse_payment_filter(params) do case Map.get(params, "payment_filter") do "paid" -> :paid @@ -550,24 +553,53 @@ defmodule MvWeb.Components.MemberFilterComponent do boolean_custom_fields, boolean_filters ) do - cond do - cycle_status_filter -> - payment_filter_label(cycle_status_filter) + active_count = + count_active_filter_categories( + cycle_status_filter, + group_filters, + fee_type_filters, + boolean_filters + ) - map_size(group_filters) > 0 -> - group_filters_label(groups, group_filters) + if active_count >= 2 do + ngettext("%{count} filter active", "%{count} filters active", active_count, + count: active_count + ) + else + cond do + cycle_status_filter -> + payment_filter_label(cycle_status_filter) - map_size(fee_type_filters) > 0 -> - fee_type_filters_label(fee_types, fee_type_filters) + map_size(group_filters) > 0 -> + group_filters_label(groups, group_filters) - map_size(boolean_filters) > 0 -> - boolean_filter_label(boolean_custom_fields, boolean_filters) + map_size(fee_type_filters) > 0 -> + fee_type_filters_label(fee_types, fee_type_filters) - true -> - gettext("Apply filters") + map_size(boolean_filters) > 0 -> + boolean_filter_label(boolean_custom_fields, boolean_filters) + + true -> + gettext("Apply filters") + end end end + defp count_active_filter_categories( + cycle_status_filter, + group_filters, + fee_type_filters, + boolean_filters + ) do + [ + cycle_status_filter, + map_size(group_filters) > 0, + map_size(fee_type_filters) > 0, + map_size(boolean_filters) > 0 + ] + |> Enum.count(& &1) + end + defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, do: gettext("All") @@ -589,15 +621,22 @@ defmodule MvWeb.Components.MemberFilterComponent do defp fee_type_filters_label(fee_types, fee_type_filters) do fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) - names = + parts = fee_type_filters - |> Enum.map(fn {fee_type_id_str, _} -> Map.get(fee_types_by_id, fee_type_id_str) end) + |> Enum.map(fn {fee_type_id_str, value} -> + fee_type_filter_part(Map.get(fee_types_by_id, fee_type_id_str), value) + end) |> Enum.reject(&is_nil/1) - label = Enum.join(names, ", ") + label = Enum.join(parts, ", ") truncate_label(label, 30) end + defp fee_type_filter_part(nil, _value), do: nil + + defp fee_type_filter_part(name, :not_in), do: gettext("without %{name}", name: name) + defp fee_type_filter_part(name, _), do: name + # Get payment filter label defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(:paid), do: gettext("Paid") @@ -671,70 +710,27 @@ defmodule MvWeb.Components.MemberFilterComponent do end end - # Get CSS classes for per-group filter label based on current state - defp group_filter_label_class(group_filters, group_id, expected_value) do + # Shared CSS classes for in/not_in filter labels (groups and fee types) + defp in_not_in_filter_label_class(filters, id, expected_value) do base_classes = "join-item btn btn-sm" - current_value = Map.get(group_filters, to_string(group_id)) + current_value = Map.get(filters, to_string(id)) is_active = current_value == expected_value - cond do - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - expected_value == :in -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - expected_value == :not_in -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" + case {expected_value, is_active} do + {_, false} -> "#{base_classes} btn" + {nil, true} -> "#{base_classes} btn-active" + {:in, true} -> "#{base_classes} btn-success btn-active" + {:not_in, true} -> "#{base_classes} btn-error btn-active" + _ -> "#{base_classes} btn-outline" end end - # Get CSS classes for per-fee-type filter label based on current state + defp group_filter_label_class(group_filters, group_id, expected_value) do + in_not_in_filter_label_class(group_filters, group_id, expected_value) + end + defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do - base_classes = "join-item btn btn-sm" - current_value = Map.get(fee_type_filters, to_string(fee_type_id)) - is_active = current_value == expected_value - - cond do - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - expected_value == :in -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - expected_value == :not_in -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" - end + in_not_in_filter_label_class(fee_type_filters, fee_type_id, expected_value) end # Get CSS classes for boolean filter label based on current state diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c745a3d..e2e037d 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -38,13 +38,14 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility + alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() - @group_filter_prefix "group_" - @fee_type_filter_prefix "fee_type_" + @group_filter_prefix Mv.Constants.group_filter_prefix() + @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() # Maximum number of boolean custom field filters allowed per request (DoS protection) @max_boolean_filters Mv.Constants.max_boolean_filters() @@ -222,16 +223,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - new_show_current, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -306,14 +298,10 @@ defmodule MvWeb.MemberLive.Index do # URL sync - push_patch happens synchronously in the event handler query_params = build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] + opts_for_query_params(socket, %{ + sort_field: export_sort_field(socket.assigns.sort_field), + sort_order: export_sort_order(socket.assigns.sort_order) + }) ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -345,16 +333,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - q, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{query: q})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -374,16 +353,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -409,16 +379,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - updated_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{boolean_filters: updated_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -446,16 +407,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - group_filters, - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket, %{group_filters: group_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -483,16 +435,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - fee_type_filters - ) + build_query_params(opts_for_query_params(socket, %{fee_type_filters: fee_type_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -502,9 +445,18 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: new_path, replace: true)} end - @impl true + # Backward compatibility: tuple form delegates to map form def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do - handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}, %{}}, socket) + handle_info( + {:reset_all_filters, + %{ + cycle_status_filter: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: %{}, + fee_type_filters: %{} + }}, + socket + ) end def handle_info( @@ -512,7 +464,13 @@ defmodule MvWeb.MemberLive.Index do socket ) do handle_info( - {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, %{}}, + {:reset_all_filters, + %{ + cycle_status_filter: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: group_filters, + fee_type_filters: %{} + }}, socket ) end @@ -522,26 +480,30 @@ defmodule MvWeb.MemberLive.Index do fee_type_filters}, socket ) do + handle_info( + {:reset_all_filters, + %{ + cycle_status_filter: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: group_filters, + fee_type_filters: fee_type_filters + }}, + socket + ) + end + + def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket - |> assign(:cycle_status_filter, cycle_status_filter) - |> assign(:group_filters, group_filters) - |> assign(:fee_type_filters, fee_type_filters) - |> assign(:boolean_custom_field_filters, boolean_filters) + |> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter)) + |> assign(:group_filters, Map.get(opts, :group_filters, %{})) + |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) + |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> load_members() |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - boolean_filters, - fee_type_filters - ) + build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -801,16 +763,7 @@ defmodule MvWeb.MemberLive.Index do defp push_field_selection_url(socket) do query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters, - socket.assigns[:fee_type_filters] - ) + build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" @@ -821,22 +774,27 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :user_field_selection, selection) end - defp build_query_params( - query, - sort_field, - sort_order, - cycle_status_filter, - group_filters, - show_current_cycle, - boolean_filters, - fee_type_filters - ) do - base_params = build_base_params(query, sort_field, sort_order) - base_params = add_cycle_status_filter(base_params, cycle_status_filter) - base_params = add_group_filters(base_params, group_filters) - base_params = add_fee_type_filters(base_params, fee_type_filters || %{}) - base_params = add_show_current_cycle(base_params, show_current_cycle) - add_boolean_filters(base_params, boolean_filters) + defp build_query_params(opts) when is_map(opts) do + base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order) + base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter) + base_params = add_group_filters(base_params, opts.group_filters || %{}) + base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) + base_params = add_show_current_cycle(base_params, opts.show_current_cycle) + add_boolean_filters(base_params, opts.boolean_filters || %{}) + end + + defp opts_for_query_params(socket, overrides \\ %{}) do + %{ + query: socket.assigns.query, + sort_field: socket.assigns.sort_field, + sort_order: socket.assigns.sort_order, + cycle_status_filter: socket.assigns.cycle_status_filter, + group_filters: socket.assigns[:group_filters] || %{}, + show_current_cycle: socket.assigns.show_current_cycle, + boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, + fee_type_filters: socket.assigns[:fee_type_filters] || %{} + } + |> Map.merge(overrides) end defp add_fee_type_filters(params, fee_type_filters) do @@ -1146,7 +1104,8 @@ defmodule MvWeb.MemberLive.Index do defp apply_one_group_filter(query, _, _), do: query - # Multiple fee type filters combine with AND: member must match all selected fee type conditions. + # Fee type filters: :in selections combine with OR (member has any of the selected types); + # :not_in selections combine with AND (member must not have type A and not have type B). defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, do: query @@ -1157,27 +1116,28 @@ defmodule MvWeb.MemberLive.Index do |> Enum.reject(&is_nil/1) |> MapSet.new() - Enum.reduce(fee_type_filters, query, fn {fee_type_id_str, value}, q -> - member? = MapSet.member?(valid_ids, fee_type_id_str) + {in_id_strs, not_in_filters} = + fee_type_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) - if member? do - apply_one_fee_type_filter(q, fee_type_id_str, value) - else - q - end - end) - end + in_uuids = + in_id_strs + |> Enum.map(fn {id_str, _} -> id_str end) + |> Enum.map(&Ecto.UUID.cast/1) + |> Enum.filter(&match?({:ok, _}, &1)) + |> Enum.map(fn {:ok, uuid} -> uuid end) - defp apply_one_fee_type_filter(query, _fee_type_id_str, nil), do: query - - defp apply_one_fee_type_filter(query, fee_type_id_str, :in) do - case Ecto.UUID.cast(fee_type_id_str) do - {:ok, fee_type_uuid} -> - Ash.Query.filter(query, expr(membership_fee_type_id == ^fee_type_uuid)) - - _ -> + query = + if in_uuids == [] do query - end + else + Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) + end + + Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q -> + apply_one_fee_type_filter(q, fee_type_id_str, :not_in) + end) end defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do @@ -1523,7 +1483,14 @@ defmodule MvWeb.MemberLive.Index do add_group_filter_entry(acc, key, value_str, prefix_len) end) - assign(socket, :group_filters, filters) + valid_group_ids = + socket.assigns.groups + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + |> MapSet.to_list() + + assign(socket, :group_filters, Map.take(filters, valid_group_ids)) end defp maybe_update_group_filters(socket, _), do: socket @@ -1544,7 +1511,14 @@ defmodule MvWeb.MemberLive.Index do add_fee_type_filter_entry(acc, key, value_str, prefix_len) end) - assign(socket, :fee_type_filters, filters) + valid_fee_type_ids = + socket.assigns.fee_types + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + |> MapSet.to_list() + + assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids)) end defp maybe_update_fee_type_filters(socket, _), do: socket @@ -1556,7 +1530,7 @@ defmodule MvWeb.MemberLive.Index do valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length if valid_id? do - case parse_fee_type_filter_value(value_str) do + case FilterParams.parse_in_not_in_value(value_str) do nil -> acc value -> Map.put(acc, fee_type_id_str, value) end @@ -1565,15 +1539,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp parse_fee_type_filter_value("in"), do: :in - defp parse_fee_type_filter_value("not_in"), do: :not_in - - defp parse_fee_type_filter_value(val) when is_binary(val) do - parse_fee_type_filter_value(String.trim(val)) - end - - defp parse_fee_type_filter_value(_), do: nil - defp add_group_filter_entry(acc, key, value_str, prefix_len) do key_str = to_string(key) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) @@ -1581,7 +1546,7 @@ defmodule MvWeb.MemberLive.Index do valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length if valid_id? do - case parse_group_filter_value(value_str) do + case FilterParams.parse_in_not_in_value(value_str) do nil -> acc value -> Map.put(acc, group_id_str, value) end @@ -1600,15 +1565,6 @@ defmodule MvWeb.MemberLive.Index do defp normalize_uuid_string(_), do: nil - defp parse_group_filter_value("in"), do: :in - defp parse_group_filter_value("not_in"), do: :not_in - - defp parse_group_filter_value(val) when is_binary(val) do - parse_group_filter_value(String.trim(val)) - end - - defp parse_group_filter_value(_), do: nil - defp determine_cycle_status_filter("paid"), do: :paid defp determine_cycle_status_filter("unpaid"), do: :unpaid defp determine_cycle_status_filter(_), do: nil diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0f07068..ec26b39 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3234,6 +3234,18 @@ msgid "Include joining cycle: When active, members pay from their joining cycle; msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus." #: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Fee types" -msgstr "Beitragsart" +msgstr "Beitragsarten" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "%{count} Filter aktiv" +msgstr[1] "%{count} Filter aktiv" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "ohne %{name}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 3ca889b..d5efdd8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3237,3 +3237,15 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee types" msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 6c6069a..9a76cc8 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3234,6 +3234,18 @@ msgid "Include joining cycle: When active, members pay from their joining cycle; msgstr "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." #: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Fee types" -msgstr "" +msgstr "Fee types" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "%{count} filter active" +msgstr[1] "%{count} filters active" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "without %{name}" From d032f1ca0c70301daddd46da65cd4bce3ed19af0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 9 Mar 2026 15:10:50 +0100 Subject: [PATCH 8/8] Run bootstrap seeds in production; add RUN_DEV_SEEDS support --- docs/admin-bootstrap-and-oidc-role-sync.md | 8 +++-- lib/mv/release.ex | 36 ++++++++++++++++++++++ priv/repo/seeds.exs | 3 ++ priv/repo/seeds_bootstrap.exs | 11 ++++++- rel/overlays/bin/docker-entrypoint.sh | 3 ++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index abbd03f..5e26c85 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -2,24 +2,26 @@ ## Overview -- **Admin bootstrap:** In production, no seeds run. The first admin user is created/updated from environment variables in the Docker entrypoint (after migrate, before server). Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. +- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (bootstrap seeds; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. - **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in. ## Admin Bootstrap (Part A) ### Environment Variables +- `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run. - `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing. - `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change). - `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret). -### Release Task +### Release Tasks +- `Mv.Release.run_seeds/0` – Runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Idempotent. - `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent. ### Entrypoint -- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs seed_admin(), then starts the server. +- rel/overlays/bin/docker-entrypoint.sh – After migrate, runs run_seeds(), then seed_admin(), then starts the server. ### Seeds (Dev/Test) diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 54bc245..00dcadf 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -6,6 +6,8 @@ defmodule Mv.Release do ## Tasks - `migrate/0` - Runs all pending Ecto migrations. + - `run_seeds/0` - Runs bootstrap seeds (fee types, custom fields, roles, settings). + In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data). - `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell to update the admin password without redeploying. @@ -26,6 +28,40 @@ defmodule Mv.Release do end end + @doc """ + Runs seed scripts so the database has required bootstrap data (and optionally dev data). + + - Always runs bootstrap seeds (fee types, custom fields, roles, system user, settings). + - If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data). + + Uses paths from the application's priv dir so it works in releases (no Mix). Idempotent. + """ + def run_seeds do + case Application.ensure_all_started(@app) do + {:ok, _} -> :ok + {:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}" + end + + priv = :code.priv_dir(@app) + bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs") + dev_path = Path.join(priv, "repo/seeds_dev.exs") + + prev = Code.compiler_options() + Code.compiler_options(ignore_module_conflict: true) + + try do + Code.eval_file(bootstrap_path) + IO.puts("✅ Bootstrap seeds completed.") + + if System.get_env("RUN_DEV_SEEDS") == "true" do + Code.eval_file(dev_path) + IO.puts("✅ Dev seeds completed.") + end + after + Code.compiler_options(prev) + end + end + def rollback(repo, version) do load_app() {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 44df447..7257f8b 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -5,6 +5,9 @@ # Bootstrap runs in all environments. Dev seeds (members, groups, sample data) # run only in dev and test. # +# In production (release): seeds are run via Mv.Release.run_seeds/0 from the +# container entrypoint. Set RUN_DEV_SEEDS=true to also run dev seeds there. +# # Compiler option ignore_module_conflict is set only during seed evaluation # so that eval_file of bootstrap/dev does not emit "redefining module" warnings; # it is always restored in `after` to avoid hiding real conflicts elsewhere. diff --git a/priv/repo/seeds_bootstrap.exs b/priv/repo/seeds_bootstrap.exs index 94b8cc0..7aafaac 100644 --- a/priv/repo/seeds_bootstrap.exs +++ b/priv/repo/seeds_bootstrap.exs @@ -1,6 +1,15 @@ # Bootstrap seeds: run in all environments (dev, test, prod). # Creates only data required for system startup: fee types, custom fields, # roles, admin user, system user, global settings. No members, no groups. +# +# Safe to run from release (no Mix): env is taken from MIX_ENV when Mix.env/0 is not available. + +mix_env = + try do + Mix.env() + rescue + UndefinedFunctionError -> (System.get_env("MIX_ENV") || "prod") |> String.to_atom() + end alias Mv.Accounts alias Mv.Membership @@ -121,7 +130,7 @@ end admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" System.put_env("ADMIN_EMAIL", admin_email) -if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and +if mix_env in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do System.put_env("ADMIN_PASSWORD", "testpassword") end diff --git a/rel/overlays/bin/docker-entrypoint.sh b/rel/overlays/bin/docker-entrypoint.sh index caa389a..fbe345d 100755 --- a/rel/overlays/bin/docker-entrypoint.sh +++ b/rel/overlays/bin/docker-entrypoint.sh @@ -4,6 +4,9 @@ set -e echo "==> Running database migrations..." /app/bin/migrate +echo "==> Running seeds (bootstrap; dev if RUN_DEV_SEEDS=true)..." +/app/bin/mv eval "Mv.Release.run_seeds()" + echo "==> Seeding admin user from ENV (ADMIN_EMAIL, ADMIN_PASSWORD)..." /app/bin/mv eval "Mv.Release.seed_admin()"