From 634b21d1bcabb2a5bd0fde7a171456a1e6387acd Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 1 Jun 2026 00:05:40 +0000 Subject: [PATCH 01/47] chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.35.2 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 512626b..a985b86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: rauthy: container_name: rauthy-dev - image: ghcr.io/sebadob/rauthy:0.35.1 + image: ghcr.io/sebadob/rauthy:0.35.2 environment: - LOCAL_TEST=true - SMTP_URL=mailcrab From 1fb6ba814ab92dd8180b44f8fd0ae1ed7cc552c7 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 1 Jun 2026 00:05:43 +0000 Subject: [PATCH 02/47] chore(deps): update dependency just to v1.51.0 --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index e72ed5f..cf63238 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.50.0 +just 1.51.0 From fd8e6ac178c00fb4e4d061c87a8cb436ce7fe856 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 11:25:03 +0200 Subject: [PATCH 03/47] refactor(types): reconcile @specs with their success typings --- lib/membership/membership.ex | 5 +++- lib/mv/authorization/permission_sets.ex | 5 ++-- lib/mv/config.ex | 16 +++++++---- lib/mv/helpers/system_actor.ex | 28 +++++++++++-------- lib/mv/membership/import/csv_parser.ex | 8 ++++-- lib/mv/membership/member_export.ex | 17 ++++++++++- lib/mv/membership/members_csv.ex | 2 +- .../membership_fees/cycle_generation_job.ex | 8 +++--- lib/mv/membership_fees/cycle_generator.ex | 10 ++++++- lib/mv/vereinfacht/client.ex | 10 +++++-- lib/mv/vereinfacht/vereinfacht.ex | 2 +- .../member_live/index/field_visibility.ex | 2 +- .../live/membership_fee_type_live/form.ex | 2 +- lib/mv_web/live/role_live/form.ex | 2 +- lib/mv_web/live/user_live/form.ex | 4 +-- lib/mv_web/live_helpers.ex | 5 +++- lib/mv_web/translations/field_types.ex | 4 ++- 17 files changed, 92 insertions(+), 38 deletions(-) diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 7fa35dc..72be69b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -836,7 +836,10 @@ defmodule Mv.Membership do - `{:ok, rejected_request}` - Rejected JoinRequest - `{:error, error}` - Status error or authorization error """ - @spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()} + @spec reject_join_request(String.t(), keyword()) :: + {:ok, JoinRequest.t()} + | {:ok, JoinRequest.t(), [Ash.Notifier.Notification.t()]} + | {:error, term()} def reject_join_request(id, opts \\ []) do actor = Keyword.get(opts, :actor) diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index 3ffae93..ae84cdb 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -43,6 +43,7 @@ defmodule Mv.Authorization.PermissionSets do pattern matches and map lookups with no database queries or external calls. """ + @type permission_set_name :: :own_data | :read_only | :normal_user | :admin @type scope :: :own | :linked | :all @type action :: :read | :create | :update | :destroy @@ -88,7 +89,7 @@ defmodule Mv.Authorization.PermissionSets do iex> PermissionSets.all_permission_sets() [:own_data, :read_only, :normal_user, :admin] """ - @spec all_permission_sets() :: [atom()] + @spec all_permission_sets() :: [permission_set_name(), ...] def all_permission_sets do [:own_data, :read_only, :normal_user, :admin] end @@ -107,7 +108,7 @@ defmodule Mv.Authorization.PermissionSets do iex> PermissionSets.get_permissions(:invalid) ** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin] """ - @spec get_permissions(atom()) :: permission_set() + @spec get_permissions(permission_set_name()) :: permission_set() def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do raise ArgumentError, diff --git a/lib/mv/config.ex b/lib/mv/config.ex index 870d1d3..f198191 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -409,7 +409,7 @@ defmodule Mv.Config do @doc """ Returns the OIDC groups claim name (default "groups"). ENV first, then Settings. """ - @spec oidc_groups_claim() :: String.t() | nil + @spec oidc_groups_claim() :: String.t() def oidc_groups_claim do case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do nil -> "groups" @@ -492,7 +492,7 @@ defmodule Mv.Config do - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT` - Settings mode: read from Settings only """ - @spec smtp_port() :: non_neg_integer() | nil + @spec smtp_port() :: pos_integer() | nil def smtp_port do if smtp_env_mode?() do parse_smtp_port_env(System.get_env("SMTP_PORT")) @@ -638,9 +638,15 @@ defmodule Mv.Config do """ @spec mail_from_name() :: String.t() def mail_from_name do - case System.get_env("MAIL_FROM_NAME") do - nil -> get_from_settings(:smtp_from_name) || "Mila" - value -> trim_nil(value) || "Mila" + name = + case System.get_env("MAIL_FROM_NAME") do + nil -> get_from_settings(:smtp_from_name) + value -> trim_nil(value) + end + + case name do + nil -> "Mila" + name -> name end end diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex index 8cd93d2..7b86a3c 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -225,7 +225,10 @@ defmodule Mv.Helpers.SystemActor do # This allows configuration via SYSTEM_ACTOR_EMAIL env var @spec system_user_email_config() :: String.t() defp system_user_email_config do - System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local" + case System.get_env("SYSTEM_ACTOR_EMAIL") do + nil -> "system@mila.local" + email -> email + end end # Loads the system actor from the database @@ -257,7 +260,7 @@ defmodule Mv.Helpers.SystemActor do end # Handles database error when loading system user - @spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return() + @spec handle_system_user_error({:error, Ash.Error.t()}) :: Mv.Accounts.User.t() | no_return() defp handle_system_user_error(error) do case load_admin_user_fallback() do {:ok, admin_user} -> @@ -393,15 +396,18 @@ defmodule Mv.Helpers.SystemActor do # 1. Only creates system user with known email # 2. Only called during system actor initialization (bootstrap) # 3. Once created, all subsequent operations use proper authorization - Accounts.create_user!(%{email: system_user_email_config()}, - upsert?: true, - upsert_identity: :unique_email, - authorize?: false - ) - |> Ash.Changeset.for_update(:update_internal, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) + user = + Accounts.create_user!(%{email: system_user_email_config()}, + upsert?: true, + upsert_identity: :unique_email, + authorize?: false + ) + |> Ash.Changeset.for_update(:update_internal, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) + + %Accounts.User{} = user end # Finds a user by email address diff --git a/lib/mv/membership/import/csv_parser.ex b/lib/mv/membership/import/csv_parser.ex index 2de75ee..142450f 100644 --- a/lib/mv/membership/import/csv_parser.ex +++ b/lib/mv/membership/import/csv_parser.ex @@ -100,7 +100,8 @@ defmodule Mv.Membership.Import.CsvParser do |> String.replace("\r", "\n") end - @spec get_parser(String.t()) :: module() + @spec get_parser(String.t()) :: + Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon defp get_parser(","), do: Mv.Membership.Import.CsvParserComma defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon @@ -116,7 +117,10 @@ defmodule Mv.Membership.Import.CsvParser do if semicolon_score >= comma_score, do: ";", else: "," end - @spec header_field_count(module(), binary()) :: non_neg_integer() + @spec header_field_count( + Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma, + binary() + ) :: non_neg_integer() defp header_field_count(parser, header_record) do case parse_single_record(parser, header_record, nil) do {:ok, fields} -> Enum.count(fields, &(String.trim(&1) != "")) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index 16341c4..d96c82f 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -16,6 +16,21 @@ defmodule Mv.Membership.MemberExport do alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index.MembershipFeeStatus + @typedoc "Validated export parameters produced by `parse_params/1`." + @type parsed_params :: %{ + selected_ids: [String.t()], + member_fields: [String.t()], + selectable_member_fields: [String.t()], + computed_fields: [String.t()], + custom_field_ids: [String.t()], + query: String.t() | nil, + sort_field: String.t() | nil, + sort_order: String.t() | nil, + show_current_cycle: boolean(), + cycle_status_filter: :paid | :unpaid | nil, + boolean_filters: %{optional(String.t()) => boolean()} + } + @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ ["membership_fee_type", "membership_fee_status", "groups"] @computed_export_fields ["membership_fee_status"] @@ -305,7 +320,7 @@ defmodule Mv.Membership.MemberExport do :computed_fields, :custom_field_ids, :query, :sort_field, :sort_order, :show_current_cycle, :cycle_status_filter, :boolean_filters. """ - @spec parse_params(map()) :: map() + @spec parse_params(map()) :: parsed_params() def parse_params(params) do # DB fields come from "member_fields" raw_member_fields = extract_list(params, "member_fields") diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index 6331893..0a19810 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -21,7 +21,7 @@ defmodule Mv.Membership.MembersCSV do Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body. RFC 4180 escaping and formula-injection safe_cell are applied. """ - @spec export([struct() | map()], [map()]) :: iodata() + @spec export([struct() | map()], [map()]) :: [iodata()] | Enumerable.t() def export(members, columns) when is_list(members) do header = build_header(columns) rows = Enum.map(members, fn member -> build_row(member, columns) end) diff --git a/lib/mv/membership_fees/cycle_generation_job.ex b/lib/mv/membership_fees/cycle_generation_job.ex index 71a3158..b38886c 100644 --- a/lib/mv/membership_fees/cycle_generation_job.ex +++ b/lib/mv/membership_fees/cycle_generation_job.ex @@ -58,7 +58,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do {:ok, %{success: 45, failed: 0, total: 45}} """ - @spec run() :: {:ok, map()} | {:error, term()} + @spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()} def run do Logger.info("Starting membership fee cycle generation job") start_time = System.monotonic_time(:millisecond) @@ -98,7 +98,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5) """ - @spec run(keyword()) :: {:ok, map()} | {:error, term()} + @spec run(keyword()) :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()} def run(opts) when is_list(opts) do Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}") start_time = System.monotonic_time(:millisecond) @@ -135,7 +135,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do - `{:error, reason}` - Error with reason """ - @spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()} + @spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, Ash.Error.t()} def pending_members_count do today = Date.utc_today() @@ -166,7 +166,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do - `{:error, reason}` - Error with reason """ - @spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()} + @spec run_for_member(String.t()) :: CycleGenerator.generate_result() def run_for_member(member_id) when is_binary(member_id) do Logger.info("Generating cycles for member #{member_id}") CycleGenerator.generate_cycles_for_member(member_id) diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 8f1bc7c..4014d80 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -1,4 +1,11 @@ defmodule Mv.MembershipFees.CycleGenerator do + @typedoc "Aggregate counts returned by a batch cycle-generation run." + @type results_summary :: %{ + success: non_neg_integer(), + failed: non_neg_integer(), + total: non_neg_integer() + } + @moduledoc """ Module for generating membership fee cycles for members. @@ -159,7 +166,8 @@ defmodule Mv.MembershipFees.CycleGenerator do - `{:error, reason}` - Error with reason """ - @spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()} + @spec generate_cycles_for_all_members(keyword()) :: + {:ok, results_summary()} | {:error, Ash.Error.t()} def generate_cycles_for_all_members(opts \\ []) do today = Keyword.get(opts, :today, Date.utc_today()) batch_size = Keyword.get(opts, :batch_size, 10) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 3cbba71..6a81c46 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -8,6 +8,12 @@ defmodule Mv.Vereinfacht.Client do """ require Logger + @typedoc "Error reasons returned by Vereinfacht API calls." + @type error_reason :: + :not_configured + | {:request_failed, map()} + | {:http, non_neg_integer(), :html_response | binary()} + @content_type "application/vnd.api+json" @doc """ @@ -31,7 +37,7 @@ defmodule Mv.Vereinfacht.Client do {:error, :not_configured} """ @spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) :: - {:ok, :connected} | {:error, term()} + {:ok, :connected} | {:error, error_reason()} def test_connection(api_url, api_key, club_id) do if blank?(api_url) or blank?(api_key) or blank?(club_id) do {:error, :not_configured} @@ -230,7 +236,7 @@ defmodule Mv.Vereinfacht.Client do Returns the full response body (decoded JSON) for debugging/display. """ - @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} + @spec get_contact(String.t()) :: {:ok, map()} | {:error, error_reason()} def get_contact(contact_id) when is_binary(contact_id) do fetch_contact(contact_id, []) end diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index 83492b7..4d58f8d 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -26,7 +26,7 @@ defmodule Mv.Vereinfacht do - `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403) - `{:error, {:request_failed, reason}}` – network/transport error """ - @spec test_connection() :: {:ok, :connected} | {:error, term()} + @spec test_connection() :: {:ok, :connected} | {:error, Mv.Vereinfacht.Client.error_reason()} def test_connection do Client.test_connection( Mv.Config.vereinfacht_api_url(), diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index df20d25..52ebe86 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -190,7 +190,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do These fields are not in the database; they must not be used for Ash query select/sort. Use this to filter sort options and validate sort_field. """ - @spec computed_member_fields() :: [atom()] + @spec computed_member_fields() :: [:membership_fee_status | :membership_fee_type | :groups, ...] def computed_member_fields, do: @pseudo_member_fields @doc """ diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index bfdfa2d..ebaa977 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -341,7 +341,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do end end - @spec notify_parent(any()) :: any() + @spec notify_parent(any()) :: {module(), any()} defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index 51f5cac..eb672da 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -186,7 +186,7 @@ defmodule MvWeb.RoleLive.Form do end end - @spec notify_parent(any()) :: any() + @spec notify_parent(any()) :: {module(), any()} defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 4a26078..35ce1fe 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -775,7 +775,7 @@ defmodule MvWeb.UserLive.Form do )} end - @spec notify_parent(any()) :: any() + @spec notify_parent(any()) :: {module(), any()} defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) # Helper to ignore keyboard events when dropdown is closed @@ -913,7 +913,7 @@ defmodule MvWeb.UserLive.Form do MemberResource.filter_by_email_match(members, user_email_str) end - @spec load_roles(any()) :: [Mv.Authorization.Role.t()] + @spec load_roles(any()) :: [Mv.Authorization.Role.t()] | Ash.Page.page() defp load_roles(actor) do case Authorization.list_roles(actor: actor) do {:ok, roles} -> roles diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex index 4206aa6..ebf51e2 100644 --- a/lib/mv_web/live_helpers.ex +++ b/lib/mv_web/live_helpers.ex @@ -145,7 +145,10 @@ defmodule MvWeb.LiveHelpers do end """ @spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) :: - {:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()} + {:ok, Ash.Resource.record() | nil | [Ash.Notifier.Notification.t()]} + | {:ok, Ash.Resource.record(), [Ash.Notifier.Notification.t()]} + | :ok + | {:error, AshPhoenix.Form.t()} def submit_form(form, params, actor) do AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor)) end diff --git a/lib/mv_web/translations/field_types.ex b/lib/mv_web/translations/field_types.ex index 969f20b..1580b99 100644 --- a/lib/mv_web/translations/field_types.ex +++ b/lib/mv_web/translations/field_types.ex @@ -12,7 +12,9 @@ defmodule MvWeb.Translations.FieldTypes do """ use Gettext, backend: MvWeb.Gettext - @spec label(atom()) :: String.t() + @type field_type :: :string | :integer | :boolean | :date | :email + + @spec label(field_type()) :: String.t() def label(:string), do: gettext("Text") def label(:integer), do: gettext("Number") def label(:boolean), do: gettext("Yes/No-Selection") From 5352a635c6a8584e0df24a46e5210b2818813b02 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 11:33:14 +0200 Subject: [PATCH 04/47] refactor(release): bind discarded results of side-effecting release tasks --- lib/mv/release.ex | 53 +++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 116b276..5db4751 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -22,7 +22,7 @@ defmodule Mv.Release do require Logger def migrate do - load_app() + _ = load_app() for repo <- repos() do {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) @@ -75,14 +75,14 @@ defmodule Mv.Release do dev_path = Path.join(priv, "repo/seeds_dev.exs") prev = Code.compiler_options() - Code.compiler_options(ignore_module_conflict: true) + _ = Code.compiler_options(ignore_module_conflict: true) try do - Code.eval_file(bootstrap_path) + _ = Code.eval_file(bootstrap_path) IO.puts("✅ Bootstrap seeds completed.") if System.get_env("RUN_DEV_SEEDS") == "true" do - Code.eval_file(dev_path) + _ = Code.eval_file(dev_path) IO.puts("✅ Dev seeds completed.") end after @@ -92,7 +92,7 @@ defmodule Mv.Release do end def rollback(repo, version) do - load_app() + _ = load_app() {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) end @@ -139,10 +139,11 @@ defmodule Mv.Release do {:ok, %Role{} = admin_role} -> case get_user_by_email(email) do {:ok, %User{} = user} -> - user - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) + _ = + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) :ok @@ -189,15 +190,16 @@ defmodule Mv.Release do defp create_admin_user(email, password, admin_role) do case Accounts.create_user(%{email: email}, authorize?: false) do {:ok, user} -> - user - |> Ash.Changeset.for_update(:admin_set_password, %{password: password}) - |> Ash.update!(authorize?: false) - |> then(fn u -> - u - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + _ = + user + |> Ash.Changeset.for_update(:admin_set_password, %{password: password}) |> Ash.update!(authorize?: false) - end) + |> then(fn u -> + u + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + end) :ok @@ -207,15 +209,16 @@ defmodule Mv.Release do end defp update_admin_user(user, password, admin_role) do - user - |> Ash.Changeset.for_update(:admin_set_password, %{password: password}) - |> Ash.update!(authorize?: false) - |> then(fn u -> - u - |> Ash.Changeset.for_update(:update, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + _ = + user + |> Ash.Changeset.for_update(:admin_set_password, %{password: password}) |> Ash.update!(authorize?: false) - end) + |> then(fn u -> + u + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + end) :ok end From 04ab05f556daf6e60bf36ae312ec7b0c3aaa1ba6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 11:39:04 +0200 Subject: [PATCH 05/47] fix(member-export): forbid request without actor instead of falling through The nil-actor guard used a one-armed if and continued into the export path regardless. The CheckPagePermission plug already halts unauthenticated requests before this controller runs, so the corrected early return preserves observable behavior while removing the dead fall-through. The export action is split into per-payload clauses so the guard reads as a flat early return. --- .../controllers/member_export_controller.ex | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 9b08f5d..e9c4a2a 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -25,31 +25,33 @@ defmodule MvWeb.MemberExportController do @custom_field_prefix Mv.Constants.custom_field_prefix() def export(conn, params) do - actor = current_actor(conn) - if is_nil(actor), do: return_forbidden(conn) - - case params["payload"] do - nil -> - conn - |> put_status(400) - |> put_resp_content_type("application/json") - |> json(%{error: "payload required"}) - - payload when is_binary(payload) -> - case Jason.decode(payload) do - {:ok, decoded} when is_map(decoded) -> - parsed = parse_and_validate(decoded) - run_export(conn, actor, parsed) - - _ -> - conn - |> put_status(400) - |> put_resp_content_type("application/json") - |> json(%{error: "invalid JSON"}) - end + case current_actor(conn) do + nil -> return_forbidden(conn) + actor -> export_with_actor(conn, actor, params["payload"]) end end + defp export_with_actor(conn, actor, payload) when is_binary(payload) do + case Jason.decode(payload) do + {:ok, decoded} when is_map(decoded) -> + run_export(conn, actor, parse_and_validate(decoded)) + + _ -> + json_error(conn, "invalid JSON") + end + end + + defp export_with_actor(conn, _actor, _payload) do + json_error(conn, "payload required") + end + + defp json_error(conn, message) do + conn + |> put_status(400) + |> put_resp_content_type("application/json") + |> json(%{error: message}) + end + defp current_actor(conn) do conn.assigns[:current_user] |> Actor.ensure_loaded() From 848f0cd013832c3e153f4fdff17db2c97d8314a0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 11:42:57 +0200 Subject: [PATCH 06/47] refactor(types): bind intentionally discarded side-effecting results --- lib/membership/member.ex | 8 ++++---- .../member/changes/unrelate_user_when_argument_nil.ex | 7 ++++--- lib/mv/membership_fees/cycle_generator.ex | 4 ++-- lib/mv/vereinfacht/sync_flash.ex | 7 ++++--- lib/mv_web/live/auth/sign_in_live.ex | 4 ++-- lib/mv_web/live/auth/sign_out_live.ex | 4 ++-- lib/mv_web/live/global_settings_live.ex | 2 +- lib/mv_web/live/membership_fee_type_live/form.ex | 2 +- lib/mv_web/live/role_live/form.ex | 2 +- lib/mv_web/live/user_live/form.ex | 2 +- lib/mv_web/live_helpers.ex | 2 +- lib/mv_web/router.ex | 2 +- 12 files changed, 24 insertions(+), 22 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 85f5562..4959e78 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -939,7 +939,7 @@ defmodule Mv.Membership.Member do # Already in transaction: use advisory lock directly # Returns {:ok, notifications} - notifications should be returned to after_action hook defp regenerate_cycles_in_transaction(member, today, lock_key) do - EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) + _ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) end @@ -947,7 +947,7 @@ defmodule Mv.Membership.Member do # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action) defp regenerate_cycles_new_transaction(member, today, lock_key) do Repo.transaction(fn -> - EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) + _ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do {:ok, notifications} -> @@ -1093,7 +1093,7 @@ defmodule Mv.Membership.Member do initiator: initiator ) do {:ok, cycles, notifications} -> - send_notifications_if_any(notifications) + _ = send_notifications_if_any(notifications) log_cycle_generation_success(member, cycles, notifications, sync: true, @@ -1112,7 +1112,7 @@ defmodule Mv.Membership.Member do initiator: initiator ) do {:ok, cycles, notifications} -> - send_notifications_if_any(notifications) + _ = send_notifications_if_any(notifications) log_cycle_generation_success(member, cycles, notifications, sync: false, diff --git a/lib/membership/member/changes/unrelate_user_when_argument_nil.ex b/lib/membership/member/changes/unrelate_user_when_argument_nil.ex index dc4d097..da8a291 100644 --- a/lib/membership/member/changes/unrelate_user_when_argument_nil.ex +++ b/lib/membership/member/changes/unrelate_user_when_argument_nil.ex @@ -37,9 +37,10 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do {:ok, %{user: user}} when not is_nil(user) -> # User's :update action only accepts [:email]; use :update_user so # manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id. - user - |> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts) - |> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false) + _ = + user + |> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts) + |> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false) changeset diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 4014d80..189f40a 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -122,7 +122,7 @@ defmodule Mv.MembershipFees.CycleGenerator do lock_key = Member.advisory_lock_key_for_member_id(member.id) Repo.transaction(fn -> - EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) + _ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) case do_generate_cycles(member, today, opts) do {:ok, cycles, notifications} -> @@ -220,7 +220,7 @@ defmodule Mv.MembershipFees.CycleGenerator do defp process_member_cycle_generation(member, today) do case generate_cycles_for_member(member, today: today) do {:ok, _cycles, notifications} = ok -> - send_notifications_for_batch_job(notifications) + _ = send_notifications_for_batch_job(notifications) {member.id, ok} {:error, _reason} = err -> diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex index 874a717..5c643b6 100644 --- a/lib/mv/vereinfacht/sync_flash.ex +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -37,9 +37,10 @@ defmodule Mv.Vereinfacht.SyncFlash do def create_table! do # :public so any process can write (SyncContact runs in LiveView/Ash transaction process, # not the process that created the table). :protected would restrict writes to the creating process. - if :ets.whereis(@table) == :undefined do - :ets.new(@table, [:set, :public, :named_table]) - end + _ = + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:set, :public, :named_table]) + end :ok end diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex index fb41f1b..c519914 100644 --- a/lib/mv_web/live/auth/sign_in_live.ex +++ b/lib/mv_web/live/auth/sign_in_live.ex @@ -30,8 +30,8 @@ defmodule MvWeb.SignInLive do # Set both backend-specific and global locale so Gettext.get_locale/0 and # Gettext.get_locale/1 both return the correct value (important for the # language-selector `selected` attribute in Layouts.public_page). - Gettext.put_locale(MvWeb.Gettext, locale) - Gettext.put_locale(locale) + _ = Gettext.put_locale(MvWeb.Gettext, locale) + _ = Gettext.put_locale(locale) # Prepend DE-specific overrides when locale is German so that components # without _gettext support (e.g. HorizontalRule) still render in German. diff --git a/lib/mv_web/live/auth/sign_out_live.ex b/lib/mv_web/live/auth/sign_out_live.ex index 2a0e0df..569337a 100644 --- a/lib/mv_web/live/auth/sign_out_live.ex +++ b/lib/mv_web/live/auth/sign_out_live.ex @@ -16,8 +16,8 @@ defmodule MvWeb.SignOutLive do @impl true def mount(_params, session, socket) do locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") - Gettext.put_locale(MvWeb.Gettext, locale) - Gettext.put_locale(locale) + _ = Gettext.put_locale(MvWeb.Gettext, locale) + _ = Gettext.put_locale(locale) club_name = case Membership.get_settings() do diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 6a1c926..6a456fe 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -52,7 +52,7 @@ defmodule MvWeb.GlobalSettingsLive do # Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test) locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") - Gettext.put_locale(MvWeb.Gettext, locale) + _ = Gettext.put_locale(MvWeb.Gettext, locale) actor = MvWeb.LiveHelpers.current_actor(socket) custom_fields = load_custom_fields(actor) diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index ebaa977..a4d8506 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -326,7 +326,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do case submit_form(socket.assigns.form, params, actor) do {:ok, membership_fee_type} -> - notify_parent({:saved, membership_fee_type}) + _ = notify_parent({:saved, membership_fee_type}) socket = socket diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index eb672da..2e315b9 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -165,7 +165,7 @@ defmodule MvWeb.RoleLive.Form do case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do {:ok, role} -> - notify_parent({:saved, role}) + _ = notify_parent({:saved, role}) redirect_path = if socket.assigns.return_to == "show" do diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 35ce1fe..622228d 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -734,7 +734,7 @@ defmodule MvWeb.UserLive.Form do end defp handle_save_success(socket, updated_user) do - notify_parent({:saved, updated_user}) + _ = notify_parent({:saved, updated_user}) action = get_action_name(socket.assigns.form.source.type) diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex index ebf51e2..003c36c 100644 --- a/lib/mv_web/live_helpers.ex +++ b/lib/mv_web/live_helpers.ex @@ -22,7 +22,7 @@ defmodule MvWeb.LiveHelpers do def on_mount(:default, _params, session, socket) do locale = session["locale"] || "de" - Gettext.put_locale(locale) + _ = Gettext.put_locale(locale) # Browser timezone from LiveSocket connect params (set in app.js via Intl API) connect_params = socket.private[:connect_params] || %{} diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index f42eb29..64036c9 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -188,7 +188,7 @@ defmodule MvWeb.Router do get_locale_from_cookie(conn) || extract_locale_from_headers(conn.req_headers) - Gettext.put_locale(MvWeb.Gettext, locale) + _ = Gettext.put_locale(MvWeb.Gettext, locale) conn |> put_session(:locale, locale) From c0395f16e874a4ea9d7a9b02b8df3d3d8519c654 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 11:46:54 +0200 Subject: [PATCH 07/47] fix(types): resolve unknown type references in member and authorization specs --- lib/membership/member.ex | 9 ++++++--- lib/mv_web/authorization.ex | 3 +-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 4959e78..bf8a48c 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -51,6 +51,9 @@ defmodule Mv.Membership.Member do require Logger + @typedoc "An `Mv.Membership.Member` resource record." + @type t :: %__MODULE__{} + # Module constants @member_search_limit 10 @@ -791,7 +794,7 @@ defmodule Mv.Membership.Member do # nil/[] when membership_fee_type is missing. @doc false - @spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil + @spec get_current_cycle(t()) :: MembershipFeeCycle.t() | nil def get_current_cycle(member) do today = Date.utc_today() @@ -821,7 +824,7 @@ defmodule Mv.Membership.Member do end @doc false - @spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil + @spec get_last_completed_cycle(t()) :: MembershipFeeCycle.t() | nil def get_last_completed_cycle(member) do today = Date.utc_today() @@ -867,7 +870,7 @@ defmodule Mv.Membership.Member do end @doc false - @spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()] + @spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()] def get_overdue_cycles(member) do today = Date.utc_today() diff --git a/lib/mv_web/authorization.ex b/lib/mv_web/authorization.ex index d821416..de009b6 100644 --- a/lib/mv_web/authorization.ex +++ b/lib/mv_web/authorization.ex @@ -113,8 +113,7 @@ defmodule MvWeb.Authorization do iex> can_access_page?(mitglied, "/members") false """ - @spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) :: - boolean() + @spec can_access_page?(map() | nil, String.t()) :: boolean() def can_access_page?(nil, _page_path), do: false def can_access_page?(user, page_path) do From d9a5a081dfef39c1b46e0b81dc5e46bbc09ec15a Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 11:50:43 +0200 Subject: [PATCH 08/47] refactor(import): drop unreachable CSV error-formatting path consume_and_read_csv/2 and MemberCSV.prepare/2 only ever return {:error, binary()}, so the non-binary error branch and the format_error_message/* helpers it called were unreachable. Removed them and bound the remaining discarded locale/dispatch results. --- lib/mv_web/live/import_live.ex | 128 ++++++++------------------------- 1 file changed, 29 insertions(+), 99 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index f3d4941..a8c5a95 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -47,14 +47,11 @@ defmodule MvWeb.ImportLive do # after this limit is reached. @max_errors 50 - # Maximum length for error messages before truncation - @max_error_message_length 200 - @impl true def mount(_params, session, socket) do # Get locale from session for translations locale = session["locale"] || "de" - Gettext.put_locale(MvWeb.Gettext, locale) + _ = Gettext.put_locale(MvWeb.Gettext, locale) # Get club name from settings club_name = @@ -193,16 +190,6 @@ defmodule MvWeb.ImportLive do :error, gettext("Failed to prepare CSV import: %{reason}", reason: reason) )} - - {:error, error} -> - error_message = format_error_message(error) - - {:noreply, - put_flash( - socket, - :error, - gettext("Failed to prepare CSV import: %{reason}", reason: error_message) - )} end end @@ -223,64 +210,6 @@ defmodule MvWeb.ImportLive do {:noreply, socket} end - # Formats error messages for user-friendly display. - # - # Handles various error types including Ash errors, maps with message fields, - # lists of errors, and fallback formatting for unknown types. - @spec format_error_message(any()) :: String.t() - defp format_error_message(error) do - case error do - %Ash.Error.Invalid{} = ash_error -> - format_ash_error(ash_error) - - %{message: msg} when is_binary(msg) -> - msg - - %{errors: errors} when is_list(errors) -> - format_error_list(errors) - - reason when is_binary(reason) -> - reason - - other -> - format_unknown_error(other) - end - end - - # Formats Ash validation errors for display - defp format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do - Enum.map_join(errors, ", ", &format_single_error/1) - end - - defp format_ash_error(error) do - format_unknown_error(error) - end - - # Formats a list of errors into a readable string - defp format_error_list(errors) do - Enum.map_join(errors, ", ", &format_single_error/1) - end - - # Formats a single error item - defp format_single_error(error) when is_map(error) do - Map.get(error, :message) || Map.get(error, :field) || inspect(error, limit: :infinity) - end - - defp format_single_error(error) do - to_string(error) - end - - # Formats unknown error types with truncation for very long messages - defp format_unknown_error(other) do - error_str = inspect(other, limit: :infinity, pretty: true) - - if String.length(error_str) > @max_error_message_length do - String.slice(error_str, 0, @max_error_message_length - 3) <> "..." - else - error_str - end - end - @impl true def handle_info({:process_chunk, idx}, socket) do case socket.assigns do @@ -337,32 +266,33 @@ defmodule MvWeb.ImportLive do actor: actor ] - if Config.sql_sandbox?() do - run_chunk_with_locale( - locale, - chunk, - import_state.column_map, - import_state.custom_field_map, - opts, - live_view_pid, - idx - ) - else - Task.Supervisor.start_child( - Mv.TaskSupervisor, - fn -> - run_chunk_with_locale( - locale, - chunk, - import_state.column_map, - import_state.custom_field_map, - opts, - live_view_pid, - idx - ) - end - ) - end + _ = + if Config.sql_sandbox?() do + run_chunk_with_locale( + locale, + chunk, + import_state.column_map, + import_state.custom_field_map, + opts, + live_view_pid, + idx + ) + else + Task.Supervisor.start_child( + Mv.TaskSupervisor, + fn -> + run_chunk_with_locale( + locale, + chunk, + import_state.column_map, + import_state.custom_field_map, + opts, + live_view_pid, + idx + ) + end + ) + end {:noreply, socket} end @@ -378,7 +308,7 @@ defmodule MvWeb.ImportLive do live_view_pid, idx ) do - Gettext.put_locale(MvWeb.Gettext, locale) + _ = Gettext.put_locale(MvWeb.Gettext, locale) ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx) end From 05f66ccf74a8d28ce9fcde8fca47d68c65ef46ea Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 11:56:44 +0200 Subject: [PATCH 09/47] refactor(types): remove dead catch-all clauses unreachable per success typing --- .../changes/filter_form_data_by_allowlist.ex | 14 ++++---------- lib/membership/member.ex | 2 -- .../changes/set_membership_fee_start_date.ex | 7 ------- lib/mv/config.ex | 7 ------- lib/mv/mailer.ex | 2 -- lib/mv/membership/import/member_csv.ex | 2 -- lib/mv/membership/member_export.ex | 2 -- lib/mv/oidc_role_sync.ex | 2 -- lib/mv_web/controllers/auth_controller.ex | 2 -- .../live/components/member_filter_component.ex | 1 - lib/mv_web/live/group_live/show.ex | 6 ------ lib/mv_web/live/join_live.ex | 2 -- lib/mv_web/live/member_live/index.ex | 10 ---------- .../live/member_live/index/field_selection.ex | 4 ---- priv/gettext/de/LC_MESSAGES/default.po | 10 +++++----- priv/gettext/default.pot | 5 ----- priv/gettext/en/LC_MESSAGES/default.po | 10 +++++----- 17 files changed, 14 insertions(+), 74 deletions(-) diff --git a/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex index 5de15c8..8dae2d1 100644 --- a/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex +++ b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex @@ -17,16 +17,10 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{} allowlist_ids = - case Membership.get_join_form_allowlist() do - list when is_list(list) -> - list - |> Enum.map(fn item -> item.id end) - |> MapSet.new() - |> MapSet.difference(MapSet.new(@typed_fields)) - - _ -> - MapSet.new() - end + Membership.get_join_form_allowlist() + |> Enum.map(fn item -> item.id end) + |> MapSet.new() + |> MapSet.difference(MapSet.new(@typed_fields)) filtered = form_data diff --git a/lib/membership/member.ex b/lib/membership/member.ex index bf8a48c..cddc23f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -1234,8 +1234,6 @@ defmodule Mv.Membership.Member do |> String.replace("_", "\\_") end - defp sanitize_search_query(_), do: "" - # ============================================================================ # Search Filter Builders # ============================================================================ diff --git a/lib/membership_fees/changes/set_membership_fee_start_date.ex b/lib/membership_fees/changes/set_membership_fee_start_date.ex index 0e9cf00..8f5aa56 100644 --- a/lib/membership_fees/changes/set_membership_fee_start_date.ex +++ b/lib/membership_fees/changes/set_membership_fee_start_date.ex @@ -26,8 +26,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do """ use Ash.Resource.Change - require Logger - alias Mv.MembershipFees.CalendarCycles @impl true @@ -83,11 +81,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do field: :membership_fee_type_id, message: "not found" ) - - {:error, reason} -> - # Log warning for other unexpected errors - Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}") - changeset end end diff --git a/lib/mv/config.ex b/lib/mv/config.ex index f198191..750a7db 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -207,8 +207,6 @@ defmodule Mv.Config do end end - defp derive_app_url_from_api_url(_), do: nil - @doc """ Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set). """ @@ -251,7 +249,6 @@ defmodule Mv.Config do case System.get_env(key) do nil -> false v when is_binary(v) -> String.trim(v) != "" - _ -> false end end @@ -270,9 +267,6 @@ defmodule Mv.Config do value when is_binary(value) -> v = String.trim(value) |> String.downcase() v in ["true", "1", "yes"] - - _ -> - false end end @@ -328,7 +322,6 @@ defmodule Mv.Config do defp present?(nil), do: false defp present?(s) when is_binary(s), do: String.trim(s) != "" - defp present?(_), do: false # --------------------------------------------------------------------------- # OIDC authentication diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index ec8f357..1e55b6e 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -190,6 +190,4 @@ defmodule Mv.Mailer do defp valid_email?(email) when is_binary(email) do Regex.match?(@email_regex, String.trim(email)) end - - defp valid_email?(_), do: false end diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index 23e0d93..dda1d04 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -210,8 +210,6 @@ defmodule Mv.Membership.Import.MemberCSV do MapSet.member?(HeaderMapper.known_member_fields(), normalized) end - defp member_field?(_), do: false - # Validates that row count doesn't exceed limit defp validate_row_count(rows, max_rows) do if length(rows) > max_rows do diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index d96c82f..81cc164 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -522,6 +522,4 @@ defmodule Mv.Membership.MemberExport do other -> other end) end - - defp normalize_computed_fields(_), do: [] end diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex index a13748a..0f6467c 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -87,8 +87,6 @@ defmodule Mv.OidcRoleSync do ArgumentError -> nil end - defp safe_get_atom(_map, _key), do: nil - defp peek_jwt_claims(token) do parts = String.split(token, ".") diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 120c245..42bdcfa 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -335,8 +335,6 @@ defmodule MvWeb.AuthController do end end - defp redact_url(_), do: "[redacted]" - def sign_out(conn, _params) do conn = clear_session(conn, :mv) |> put_flash(:success, gettext("You are now signed out")) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 99ee2c5..b66d259 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -935,7 +935,6 @@ defmodule MvWeb.Components.MemberFilterComponent do {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 diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index b9f22c8..7cd4378 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -836,12 +836,6 @@ defmodule MvWeb.GroupLive.Show do end end - defp perform_add_members(socket, _group, _member_ids, _actor) do - {:noreply, - socket - |> put_flash(:error, gettext("No members selected."))} - end - defp handle_successful_add_members(socket, group, actor) do socket = reload_group(socket, group.slug, actor) diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index ba0e476..b679127 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -287,8 +287,6 @@ defmodule MvWeb.JoinLive do end end - defp member_field_input_type(_), do: "text" - defp member_field_atom(field_id) when is_binary(field_id) do Mv.Constants.member_fields() |> Enum.find(&(Atom.to_string(&1) == field_id)) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index cd32513..6196fc4 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1218,8 +1218,6 @@ defmodule MvWeb.MemberLive.Index do 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) @@ -1297,8 +1295,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp valid_sort_field?(_), do: false - defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields @@ -1558,8 +1554,6 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :group_filters, Map.take(filters, valid_group_ids)) end - 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) @@ -1586,8 +1580,6 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids)) 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) @@ -1719,8 +1711,6 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields)) end - defp maybe_update_date_filters(socket, _params), do: socket - # ------------------------------------------------------------- # Custom Field Value Helpers # ------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index/field_selection.ex b/lib/mv_web/live/member_live/index/field_selection.ex index 1e77b09..846cf1d 100644 --- a/lib/mv_web/live/member_live/index/field_selection.ex +++ b/lib/mv_web/live/member_live/index/field_selection.ex @@ -103,8 +103,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do end) end - defp parse_cookie_header(_), do: %{} - @doc """ Saves field selection to cookie. @@ -218,8 +216,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do end end - defp parse_json(_), do: %{} - # Parses a comma-separated string of field names defp parse_fields_string(fields_string) do fields_string diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f03daf0..81d91f7 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2208,11 +2208,6 @@ msgstr "Keine Mitglieder in dieser Gruppe" msgid "No members selected" msgstr "Keine Mitglieder ausgewählt" -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "No members selected." -msgstr "Keine Mitglieder ausgewählt." - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." @@ -3972,3 +3967,8 @@ msgstr "Zeitraum" #, elixir-autogen, elixir-format msgid "To" msgstr "Bis" + +#~ #: lib/mv_web/live/group_live/show.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "No members selected." +#~ msgstr "Keine Mitglieder ausgewählt." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 50ceff8..5e9abca 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2209,11 +2209,6 @@ msgstr "" msgid "No members selected" msgstr "" -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "No members selected." -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9ec230f..1ae6a49 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2209,11 +2209,6 @@ msgstr "" msgid "No members selected" msgstr "" -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "No members selected." -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." @@ -3972,3 +3967,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "To" msgstr "" + +#~ #: lib/mv_web/live/group_live/show.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "No members selected." +#~ msgstr "" From c41d24113feb8c653fa165eff687000b38a14d06 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 12:00:38 +0200 Subject: [PATCH 10/47] fix(import): return readable string for unreadable upload errors File.read/1 only yields posix atoms, so the File.Error and bare-reason branches were unreachable, and :file.format_error/1 returns a charlist rather than a String. Normalize the error to a binary so it interpolates correctly in flash messages. --- lib/mv/membership/import/import_runner.ex | 8 +---- .../membership/import/import_runner_test.exs | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 test/mv/membership/import/import_runner_test.exs diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex index eccd75f..5f953d4 100644 --- a/lib/mv/membership/import/import_runner.ex +++ b/lib/mv/membership/import/import_runner.ex @@ -26,14 +26,8 @@ defmodule Mv.Membership.Import.ImportRunner do {:ok, content} -> {:ok, content} - {:error, reason} when is_atom(reason) -> - {:error, :file.format_error(reason)} - - {:error, %File.Error{reason: reason}} -> - {:error, :file.format_error(reason)} - {:error, reason} -> - {:error, Exception.message(reason)} + {:error, to_string(:file.format_error(reason))} end end diff --git a/test/mv/membership/import/import_runner_test.exs b/test/mv/membership/import/import_runner_test.exs new file mode 100644 index 0000000..88d189e --- /dev/null +++ b/test/mv/membership/import/import_runner_test.exs @@ -0,0 +1,33 @@ +defmodule Mv.Membership.Import.ImportRunnerTest do + use ExUnit.Case, async: true + + alias Mv.Membership.Import.ImportRunner + + describe "read_file_entry/2" do + test "returns {:ok, content} for a readable file" do + path = + Path.join( + System.tmp_dir!(), + "import_runner_read_#{System.unique_integer([:positive])}.csv" + ) + + File.write!(path, "email;first_name\njohn@example.com;John") + on_exit(fn -> File.rm_rf(path) end) + + assert {:ok, "email;first_name\njohn@example.com;John"} = + ImportRunner.read_file_entry(%{path: path}, %{}) + end + + test "returns {:error, message} with a binary message when the file cannot be read" do + missing_path = + Path.join( + System.tmp_dir!(), + "import_runner_missing_#{System.unique_integer([:positive])}.csv" + ) + + assert {:error, message} = ImportRunner.read_file_entry(%{path: missing_path}, %{}) + assert is_binary(message) + assert message != "" + end + end +end From 2db467d5d16d76f574a8edb7ec6c2ba4a89378ff Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 12:04:37 +0200 Subject: [PATCH 11/47] fix(pdf-export): match DateTime.from_iso8601 three-tuple when formatting cells DateTime.from_iso8601/1 returns {:ok, datetime, offset}, so the two-tuple clauses never matched and datetime cells fell through to the naive-parse fallback. Matching the real shape routes them through the intended DateTime path; UTC values render identically. --- lib/mv/membership/members_pdf.ex | 9 ++------- test/mv/membership/members_pdf_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex index b2989ca..a1c8418 100644 --- a/lib/mv/membership/members_pdf.ex +++ b/lib/mv/membership/members_pdf.ex @@ -143,7 +143,7 @@ defmodule Mv.Membership.MembersPDF do defp convert_to_template_format(export_data, locale, club_name) do # Set locale for translations - Gettext.put_locale(MvWeb.Gettext, locale) + _ = Gettext.put_locale(MvWeb.Gettext, locale) headers = Enum.map(export_data.columns, & &1.label) column_count = length(export_data.columns) @@ -211,9 +211,6 @@ defmodule Mv.Membership.MembersPDF do {:ok, datetime, _offset} -> format_datetime(datetime, locale) - {:ok, datetime} -> - format_datetime(datetime, locale) - {:error, _} -> # Try NaiveDateTime if DateTime parsing fails case NaiveDateTime.from_iso8601(iso8601_string) do @@ -257,8 +254,6 @@ defmodule Mv.Membership.MembersPDF do end end - defp format_date(_, _), do: "" - defp format_dates_in_rows(rows, columns, locale) do date_indices = find_date_column_indices(columns) @@ -321,7 +316,7 @@ defmodule Mv.Membership.MembersPDF do defp format_cell_date_datetime(cell_value, locale) do case DateTime.from_iso8601(cell_value) do - {:ok, datetime} -> format_datetime(datetime, locale) + {:ok, datetime, _offset} -> format_datetime(datetime, locale) _ -> format_cell_date_naive(cell_value, locale) end end diff --git a/test/mv/membership/members_pdf_test.exs b/test/mv/membership/members_pdf_test.exs index 78a8ca6..2b83e3b 100644 --- a/test/mv/membership/members_pdf_test.exs +++ b/test/mv/membership/members_pdf_test.exs @@ -101,6 +101,29 @@ defmodule Mv.Membership.MembersPDFTest do assert byte_size(pdf_binary) > 1000 end + test "renders date column holding an ISO8601 datetime value" do + # Regression: a date column whose value is a full datetime string must be + # parsed via DateTime.from_iso8601/1 (which returns a 3-tuple) and rendered, + # not silently dropped. + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "join_date", kind: :member_field, label: "Eintritt"} + ], + rows: [ + ["Max", "2024-01-15T14:30:00Z"] + ], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 1 + } + } + + assert {:ok, pdf_binary} = MembersPDF.render(export_data) + assert String.starts_with?(pdf_binary, "%PDF") + assert byte_size(pdf_binary) > 1000 + end + test "generates valid PDF with custom fields and computed fields" do export_data = %{ columns: [ From ec6422d450ccb2f62d447861a4ed790e99c6d05e Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 12:08:19 +0200 Subject: [PATCH 12/47] fix(membership-fees): show error for unparseable cycle date instead of crashing Date.from_iso8601/1 returns {:error, reason}, so the with else clause matching a bare :error never fired and an invalid date raised a WithClauseError. Match the real date/calendar error reasons so the user sees the validation message. --- .../show/membership_fees_component.ex | 2 +- .../member_live/show_membership_fees_test.exs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index e8ddff4..0cba316 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -1027,7 +1027,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:create_cycle_error, format_error(error))} end else - :error -> + {:error, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] -> {:noreply, socket |> assign(:create_cycle_error, gettext("Invalid date format"))} diff --git a/test/mv_web/member_live/show_membership_fees_test.exs b/test/mv_web/member_live/show_membership_fees_test.exs index 2abb0cb..59dc471 100644 --- a/test/mv_web/member_live/show_membership_fees_test.exs +++ b/test/mv_web/member_live/show_membership_fees_test.exs @@ -268,6 +268,28 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do # Should not crash assert html =~ member.first_name end + + test "create_cycle with an unparseable date shows an error instead of crashing", %{conn: conn} do + fee_type = create_fee_type(%{interval: :yearly}) + member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) + + {:ok, view, _html} = live(conn, "/members/#{member.id}") + + view + |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']") + |> render_click() + + view + |> element("button[phx-click='open_create_cycle_modal']") + |> render_click() + + html = + view + |> element("form[phx-submit='create_cycle']") + |> render_submit(%{"date" => "not-a-date", "amount" => "10"}) + + assert html =~ "Invalid date format" + end end describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do From 6a4a99f638720823fad490cf9b6c89b7ad809b79 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 12:11:59 +0200 Subject: [PATCH 13/47] refactor(types): drop guards and clauses that can never succeed --- .../membership/member/validations/email_change_permission.ex | 2 +- lib/mv/membership/member_export.ex | 3 --- lib/mv/oidc_role_sync_config.ex | 2 +- lib/mv_web/live/membership_fee_settings_live.ex | 1 - lib/mv_web/live/membership_fee_type_live/index.ex | 1 - lib/mv_web/live/user_live/form.ex | 3 +-- 6 files changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/mv/membership/member/validations/email_change_permission.ex b/lib/mv/membership/member/validations/email_change_permission.ex index 2b1c041..073da07 100644 --- a/lib/mv/membership/member/validations/email_change_permission.ex +++ b/lib/mv/membership/member/validations/email_change_permission.ex @@ -59,7 +59,7 @@ defmodule Mv.Membership.Member.Validations.EmailChangePermission do # Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor defp resolve_actor(changeset, context) do - ctx = changeset.context || %{} + ctx = changeset.context get_in(ctx, [:private, :actor]) || Map.get(ctx, :actor) || diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index 81cc164..a98b125 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -473,9 +473,6 @@ defmodule Mv.Membership.MemberExport do computed_fields, member_fields ) do - computed_fields = computed_fields || [] - member_fields = member_fields || [] - db_with_insert = Enum.flat_map(db_fields_ordered, fn f -> expand_field_with_computed(f, member_fields, computed_fields) diff --git a/lib/mv/oidc_role_sync_config.ex b/lib/mv/oidc_role_sync_config.ex index 2a8574c..bbb5770 100644 --- a/lib/mv/oidc_role_sync_config.ex +++ b/lib/mv/oidc_role_sync_config.ex @@ -15,6 +15,6 @@ defmodule Mv.OidcRoleSyncConfig do @doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"." def oidc_groups_claim do - Mv.Config.oidc_groups_claim() || "groups" + Mv.Config.oidc_groups_claim() end end diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index e153d18..15030c1 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -464,7 +464,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do Enum.map_join(error.errors, ", ", fn e -> e.message end) end - defp format_error(error) when is_binary(error), do: error defp format_error(_error), do: gettext("An error occurred") defp assign_form(%{assigns: %{settings: settings}} = socket) do diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index a34480b..65f840d 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -214,7 +214,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do Enum.map_join(error.errors, ", ", fn e -> e.message end) end - defp format_error(error) when is_binary(error), do: error defp format_error(_error), do: gettext("An error occurred") # Info card explaining the membership fee type concept diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 622228d..60763ab 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -922,7 +922,7 @@ defmodule MvWeb.UserLive.Form do end # Extract user-friendly error message from Ash.Error - @spec extract_error_message(any()) :: String.t() + @spec extract_error_message(Ash.Error.t()) :: String.t() defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do # Take first error and extract message case List.first(errors) do @@ -932,6 +932,5 @@ defmodule MvWeb.UserLive.Form do end end - defp extract_error_message(error) when is_binary(error), do: error defp extract_error_message(_), do: gettext("Unknown error") end From a7ad60805138cfc64ea1b548d7514ff11d2964ec Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 12:19:21 +0200 Subject: [PATCH 14/47] fix(auth): redirect a live-view socket in the user-required guard LiveSession.assign_new_resources/2 is typed to return a Phoenix.Socket, which made the on_mount redirect type-incompatible. The authenticated-routes live_session already assigns current_user, so the guard reads it from socket.assigns directly. Also assign the locale into the socket actually used by the no-user redirect instead of discarding it. --- lib/mv_web/live_user_auth.ex | 13 +++++------ test/mv_web/live_user_auth_test.exs | 35 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 test/mv_web/live_user_auth_test.exs diff --git a/lib/mv_web/live_user_auth.ex b/lib/mv_web/live_user_auth.ex index f3f3fc9..617b079 100644 --- a/lib/mv_web/live_user_auth.ex +++ b/lib/mv_web/live_user_auth.ex @@ -31,27 +31,24 @@ defmodule MvWeb.LiveUserAuth do end end - def on_mount(:live_user_required, _params, session, socket) do - socket = LiveSession.assign_new_resources(socket, session) - + def on_mount(:live_user_required, _params, _session, socket) do case socket.assigns do %{current_user: %{} = user} -> {:cont, assign(socket, :current_user, user)} _ -> - socket = LiveView.redirect(socket, to: ~p"/sign-in") - {:halt, socket} + {:halt, LiveView.redirect(socket, to: ~p"/sign-in")} end end def on_mount(:live_no_user, _params, session, socket) do # Set the locale for not logged in user (default from config, "de" in dev/prod). locale = session["locale"] || Application.get_env(:mv, :default_locale, "de") - Gettext.put_locale(MvWeb.Gettext, locale) - {:cont, assign(socket, :locale, locale)} + _ = Gettext.put_locale(MvWeb.Gettext, locale) + socket = assign(socket, :locale, locale) if socket.assigns[:current_user] do - {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")} + {:halt, LiveView.redirect(socket, to: ~p"/")} else {:cont, assign(socket, :current_user, nil)} end diff --git a/test/mv_web/live_user_auth_test.exs b/test/mv_web/live_user_auth_test.exs new file mode 100644 index 0000000..0f0e1ae --- /dev/null +++ b/test/mv_web/live_user_auth_test.exs @@ -0,0 +1,35 @@ +defmodule MvWeb.LiveUserAuthTest do + @moduledoc """ + Regression tests for the `MvWeb.LiveUserAuth` on_mount guards: + the unauthenticated `:live_user_required` redirect to the sign-in page and + the authenticated `:live_no_user` redirect away from the sign-in page. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + describe ":live_user_required" do + @tag role: :unauthenticated + test "unauthenticated request to a protected route is redirected to sign-in", %{conn: conn} do + assert {:error, {:redirect, %{to: to}}} = live(conn, "/members") + assert to == "/sign-in" + end + + @tag role: :admin + test "authenticated user can mount a protected route", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, "/members") + end + end + + describe ":live_no_user" do + @tag role: :admin + test "authenticated user visiting the sign-in page is redirected to root", %{conn: conn} do + assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/sign-in") + end + + @tag role: :unauthenticated + test "unauthenticated user can reach the sign-in page", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, "/sign-in") + end + end +end From b5756d8e00a5e1dda9b1e4abe1db15f408c2a72e Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 12:23:04 +0200 Subject: [PATCH 15/47] refactor(vereinfacht): gate retry skipping on runtime sandbox flag The compile-time Mix.env() comparison folded to an always-false literal under analysis. sql_sandbox?/0 reads runtime config (true only in test) and works in releases where Mix is unavailable, preserving the fast-fail-no-retry behavior in tests. --- lib/mv/vereinfacht/client.ex | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 6a81c46..999bd44 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -98,13 +98,12 @@ defmodule Mv.Vereinfacht.Client do @sync_timeout_ms 5_000 - # Resolved at compile time so Mix is never called at runtime (Mix is not available in releases). - @env Mix.env() - # In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits). + # `sql_sandbox?/0` reads runtime config (true only in test) and avoids calling Mix at runtime, + # which is unavailable in releases. defp req_http_options do opts = [receive_timeout: @sync_timeout_ms] - if @env == :test, do: [retry: false] ++ opts, else: opts + if Mv.Config.sql_sandbox?(), do: [retry: false] ++ opts, else: opts end defp post_and_parse_contact(url, body, api_key) do From 9a14cedc149ce8d7243a109c956b3de6eb087415 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 12:26:35 +0200 Subject: [PATCH 16/47] fix(repo): define all_tenants/0 as empty for non-multitenant schema --- lib/mv/repo.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/mv/repo.ex b/lib/mv/repo.ex index 0a4a04d..183c54f 100644 --- a/lib/mv/repo.ex +++ b/lib/mv/repo.ex @@ -19,4 +19,12 @@ defmodule Mv.Repo do def min_pg_version do %Version{major: 17, minor: 2, patch: 0} end + + # This app does not use schema-based multitenancy, so there are no tenant + # schemas to migrate. Returning [] keeps the AshPostgres callback total + # rather than raising the default "not defined" error. + @impl true + def all_tenants do + [] + end end From d51dcb1ac33e7562c1dc9125d1f885102f34be5a Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Jun 2026 23:35:39 +0200 Subject: [PATCH 17/47] chore(ci): make test workflow faster with test --stale --- Justfile | 26 ++++++++++++++++++++------ config/test.exs | 4 ++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Justfile b/Justfile index 54c395f..d08cef8 100644 --- a/Justfile +++ b/Justfile @@ -29,7 +29,12 @@ seed-database: start-database: docker compose up -d -ci-dev: lint audit test-fast +# Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer. +ci-dev: install-dependencies lint audit test-fast + +# Fast pre-commit check: lint + sobelow + only the affected tests (mix test --stale) +# with reduced property runs. Run the full `ci-dev` before pushing. +check: install-dependencies lint sobelow test-stale # Build the Dialyzer PLT. Idempotent — no-op once the PLT is up to date. # First build takes 5–15 min; subsequent runs are seconds. PLT files live in @@ -58,19 +63,28 @@ lint: @bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done' mix gettext.extract --check-up-to-date -audit: +# Static security scan (Sobelow). +sobelow: mix sobelow --config + +# Full security audit: Sobelow + dependency advisory scans. +audit: sobelow mix deps.audit --ignore-file .deps_audit_ignore mix hex.audit -# Run all tests -test *args: install-dependencies +# Run all tests. No install-dependencies prerequisite so single-file runs stay +# fast; run `just install-dependencies` once on a fresh checkout. +test *args: mix test {{args}} -# Run only fast tests (excludes slow/performance and UI tests) -test-fast *args: install-dependencies +# Fast tests only (excludes slow/performance and UI tests). +test-fast *args: mix test --exclude slow --exclude ui {{args}} +# Affected fast tests only (mix test --stale) with reduced property runs. +test-stale *args: + PROPERTY_RUNS=25 mix test --stale --exclude slow --exclude ui {{args}} + # Run only UI tests ui *args: install-dependencies mix test --only ui {{args}} diff --git a/config/test.exs b/config/test.exs index ef54982..7343a6a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -62,3 +62,7 @@ config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 # Ash: silence "after_transaction hooks in surrounding transaction" warning when using # Ecto sandbox (tests run in a transaction; create_member after_transaction is expected). config :ash, warn_on_transaction_hooks?: false + +# StreamData property tests: generated cases per property, overridable via PROPERTY_RUNS +# (the `just check` recipe sets it low for speed; default 100 otherwise). +config :stream_data, max_runs: String.to_integer(System.get_env("PROPERTY_RUNS") || "100") From 5c5fd567496c2028bb3aea1ede53f3c1bfc7cb39 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 01:54:49 +0200 Subject: [PATCH 18/47] fix(export): emit date custom-field values as ISO-8601 for re-import --- .../custom_field_value_formatter.ex | 12 ++++++--- .../custom_field_value_formatter_test.exs | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 test/mv/membership/custom_field_value_formatter_test.exs diff --git a/lib/mv/membership/custom_field_value_formatter.ex b/lib/mv/membership/custom_field_value_formatter.ex index 9709353..9ba9c42 100644 --- a/lib/mv/membership/custom_field_value_formatter.ex +++ b/lib/mv/membership/custom_field_value_formatter.ex @@ -4,13 +4,13 @@ defmodule Mv.Membership.CustomFieldValueFormatter do Same logic as the member overview Formatter but without Gettext or web helpers, so it can be used from the Membership context. For boolean: "Yes"/"No"; - for date: European format (dd.mm.yyyy). + for date: ISO-8601 (YYYY-MM-DD) so exported values can be re-imported. """ @doc """ Formats a custom field value for plain text (e.g. CSV). Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type - for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy. + for typing. Boolean -> "Yes"/"No", Date -> ISO-8601 (YYYY-MM-DD). """ def format_custom_field_value(nil, _custom_field), do: "" @@ -18,6 +18,10 @@ defmodule Mv.Membership.CustomFieldValueFormatter do format_value_by_type(value, type, custom_field) end + def format_custom_field_value(%Date{} = value, custom_field) do + format_value_by_type(value, :date, custom_field) + end + def format_custom_field_value(value, custom_field) when is_map(value) do type = Map.get(value, "type") || Map.get(value, "_union_type") val = Map.get(value, "value") || Map.get(value, "_union_value") @@ -41,12 +45,12 @@ defmodule Mv.Membership.CustomFieldValueFormatter do defp format_value_by_type(value, :boolean, _), do: to_string(value) defp format_value_by_type(%Date{} = date, :date, _) do - Calendar.strftime(date, "%d.%m.%Y") + Date.to_iso8601(date) end defp format_value_by_type(value, :date, _) when is_binary(value) do case Date.from_iso8601(value) do - {:ok, date} -> Calendar.strftime(date, "%d.%m.%Y") + {:ok, date} -> Date.to_iso8601(date) _ -> value end end diff --git a/test/mv/membership/custom_field_value_formatter_test.exs b/test/mv/membership/custom_field_value_formatter_test.exs new file mode 100644 index 0000000..515fa7c --- /dev/null +++ b/test/mv/membership/custom_field_value_formatter_test.exs @@ -0,0 +1,27 @@ +defmodule Mv.Membership.CustomFieldValueFormatterTest do + use ExUnit.Case, async: true + + alias Mv.Membership.CustomFieldValueFormatter + + describe "format_custom_field_value/2 for :date" do + test "formats an Ash.Union date value as ISO-8601" do + union = %Ash.Union{value: ~D[2024-03-15], type: :date} + + assert CustomFieldValueFormatter.format_custom_field_value(union, %{value_type: :date}) == + "2024-03-15" + end + + test "formats a direct Date value as ISO-8601" do + assert CustomFieldValueFormatter.format_custom_field_value(~D[2024-03-15], %{ + value_type: :date + }) == "2024-03-15" + end + + test "formats an already-stored ISO-8601 string date as ISO-8601" do + union = %Ash.Union{value: "2024-03-15", type: :date} + + assert CustomFieldValueFormatter.format_custom_field_value(union, %{value_type: :date}) == + "2024-03-15" + end + end +end From 95c7bf7a15c21359497567efc735a4a6d9af8806 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:01:09 +0200 Subject: [PATCH 19/47] feat(import): recognize group and fee-type columns and always ignore fee-status --- lib/mv/membership/import/header_mapper.ex | 97 +++++++++-- .../membership/import/header_mapper_test.exs | 164 ++++++++++++++++++ 2 files changed, 250 insertions(+), 11 deletions(-) diff --git a/lib/mv/membership/import/header_mapper.ex b/lib/mv/membership/import/header_mapper.ex index d96d96e..3047944 100644 --- a/lib/mv/membership/import/header_mapper.ex +++ b/lib/mv/membership/import/header_mapper.ex @@ -47,10 +47,10 @@ defmodule Mv.Membership.Import.HeaderMapper do "e-mail" iex> HeaderMapper.build_maps(["Email", "First Name"], []) - {:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}} + {:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}} iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}]) - {:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}} + {:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}} """ @type column_map :: %{atom() => non_neg_integer()} @@ -60,6 +60,31 @@ defmodule Mv.Membership.Import.HeaderMapper do # Required member fields @required_member_fields [:email] + # Fee-status header variants that must never be imported (computed/read-only field). + # Stored already-normalized; checked before member, custom, groups, and fee-type mapping. + # Maintain this list when new locale translations for fee-status are added. + @ignored_normalized [ + "membershipfeestatus", + "mitgliedsbeitragsstatus", + "bezahlstatus" + ] + + # Normalized header variants for the groups column. The column is resolved to + # group associations during import; it is never a member or custom field. + @groups_column_normalized [ + "groups", + "gruppen", + "gruppe" + ] + + # Normalized header variants for the membership fee-type column. The column is + # resolved to a MembershipFeeType during import; it is never a member or custom field. + @fee_type_column_normalized [ + "membershipfeetype", + "feetype", + "beitragsart" + ] + # Canonical member fields with their raw variants # These will be normalized at runtime when building the lookup map @member_field_variants_raw %{ @@ -239,30 +264,79 @@ defmodule Mv.Membership.Import.HeaderMapper do ## Returns - - `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success + - `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers, + ignored: [non_neg_integer], groups_column_index: non_neg_integer | nil, + fee_type_column_index: non_neg_integer | nil}}` on success - `{:error, reason}` on error (missing required field, duplicate headers) + The `ignored` list holds the indices of fee-status columns (computed/read-only), + which are never mapped to member or custom fields. + ## Examples iex> build_maps(["Email", "First Name"], []) - {:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}} + {:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}} iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}]) - {:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}} + {:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}} """ @spec build_maps([String.t()], [map()]) :: - {:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}} + {:ok, + %{ + member: column_map(), + custom: custom_field_map(), + unknown: unknown_headers(), + ignored: [non_neg_integer()], + groups_column_index: non_neg_integer() | nil, + fee_type_column_index: non_neg_integer() | nil + }} | {:error, String.t()} def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do - with {:ok, member_map, unknown_after_member} <- build_member_map(headers), + ignored = ignored_indices(headers) + groups_column_index = first_matching_index(headers, @groups_column_normalized) + fee_type_column_index = first_matching_index(headers, @fee_type_column_normalized) + + reserved = + [groups_column_index, fee_type_column_index | ignored] + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + with {:ok, member_map, unknown_after_member} <- build_member_map(headers, reserved), {:ok, custom_map, unknown_after_custom} <- build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1)) - {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} + + {:ok, + %{ + member: member_map, + custom: custom_map, + unknown: unknown, + ignored: ignored, + groups_column_index: groups_column_index, + fee_type_column_index: fee_type_column_index + }} end end + # Returns the index of the first header whose normalized form is in `variants`, + # or nil if none match. + defp first_matching_index(headers, variants) do + headers + |> Enum.with_index() + |> Enum.find_value(fn {header, index} -> + if normalize_header(header) in variants, do: index + end) + end + + # Returns the column indices whose normalized header is in the fee-status ignore list. + defp ignored_indices(headers) do + headers + |> Enum.with_index() + |> Enum.filter(fn {header, _index} -> normalize_header(header) in @ignored_normalized end) + |> Enum.map(fn {_header, index} -> index end) + end + # --- Private Functions --- # Transliterates German umlauts and special characters @@ -304,13 +378,14 @@ defmodule Mv.Membership.Import.HeaderMapper do |> String.replace(" ", "") end - # Builds member field column map - defp build_member_map(headers) do + # Builds member field column map, skipping reserved (e.g. ignored) indices. + defp build_member_map(headers, reserved) do result = headers |> Enum.with_index() |> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} -> - normalized = normalize_header(header) + normalized = + if MapSet.member?(reserved, index), do: "", else: normalize_header(header) case process_member_header(header, index, normalized, acc_map, %{}) do {:error, reason} -> diff --git a/test/mv/membership/import/header_mapper_test.exs b/test/mv/membership/import/header_mapper_test.exs index 2f4fcad..f5519d8 100644 --- a/test/mv/membership/import/header_mapper_test.exs +++ b/test/mv/membership/import/header_mapper_test.exs @@ -1,5 +1,6 @@ defmodule Mv.Membership.Import.HeaderMapperTest do use ExUnit.Case, async: true + use ExUnitProperties alias Mv.Membership.Import.HeaderMapper @@ -272,4 +273,167 @@ defmodule Mv.Membership.Import.HeaderMapperTest do assert unknown == [] end end + + describe "build_maps/2 fee-status ignore list" do + test "places fee-status variants in ignored, not member or custom map" do + headers = ["email", "Bezahlstatus"] + + assert {:ok, result} = HeaderMapper.build_maps(headers, []) + + assert result.member[:email] == 0 + assert result.custom == %{} + assert result.ignored == [1] + refute Map.has_key?(result.member, :bezahlstatus) + end + + test "ignores membership_fee_status snake-case variant" do + headers = ["email", "membership_fee_status"] + + assert {:ok, result} = HeaderMapper.build_maps(headers, []) + + assert result.ignored == [1] + assert result.custom == %{} + end + + test "ignores German Mitgliedsbeitragsstatus variant" do + headers = ["email", "Mitgliedsbeitragsstatus"] + + assert {:ok, result} = HeaderMapper.build_maps(headers, []) + + assert result.ignored == [1] + end + + test "fee-status takes priority over a same-named custom field" do + headers = ["email", "Bezahlstatus"] + custom_fields = [%{id: "cf1", name: "Bezahlstatus"}] + + assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields) + + assert result.ignored == [1] + assert result.custom == %{} + end + + test "result carries groups_column_index and fee_type_column_index keys" do + assert {:ok, result} = HeaderMapper.build_maps(["email"], []) + + assert Map.has_key?(result, :groups_column_index) + assert Map.has_key?(result, :fee_type_column_index) + end + end + + describe "build_maps/2 groups column detection" do + test "detects German Gruppen variant and excludes it from member/custom maps" do + headers = ["email", "Gruppen"] + + assert {:ok, result} = HeaderMapper.build_maps(headers, []) + + assert result.groups_column_index == 1 + assert result.custom == %{} + assert result.unknown == [] + refute Map.has_key?(result.member, :gruppen) + end + + test "detects English Groups variant" do + headers = ["email", "Groups"] + + assert {:ok, result} = HeaderMapper.build_maps(headers, []) + + assert result.groups_column_index == 1 + end + + test "detects singular Gruppe and lowercase groups variants" do + assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "Gruppe"], []) + assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "groups"], []) + end + + test "groups column takes priority over a same-named custom field" do + headers = ["email", "Gruppen"] + custom_fields = [%{id: "cf1", name: "Gruppen"}] + + assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields) + + assert result.groups_column_index == 1 + assert result.custom == %{} + end + + test "groups_column_index is nil when no groups column present" do + assert {:ok, %{groups_column_index: nil}} = HeaderMapper.build_maps(["email"], []) + end + end + + describe "build_maps/2 fee-type column detection" do + test "detects German Beitragsart variant and excludes it from member/custom maps" do + headers = ["email", "Beitragsart"] + + assert {:ok, result} = HeaderMapper.build_maps(headers, []) + + assert result.fee_type_column_index == 1 + assert result.custom == %{} + assert result.unknown == [] + end + + test "detects English fee type variants" do + assert {:ok, %{fee_type_column_index: 1}} = + HeaderMapper.build_maps(["email", "Fee Type"], []) + + assert {:ok, %{fee_type_column_index: 1}} = + HeaderMapper.build_maps(["email", "fee type"], []) + + assert {:ok, %{fee_type_column_index: 1}} = + HeaderMapper.build_maps(["email", "fee_type"], []) + + assert {:ok, %{fee_type_column_index: 1}} = + HeaderMapper.build_maps(["email", "membership_fee_type"], []) + end + + test "fee-type column takes priority over a same-named custom field" do + headers = ["email", "Beitragsart"] + custom_fields = [%{id: "cf1", name: "Beitragsart"}] + + assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields) + + assert result.fee_type_column_index == 1 + assert result.custom == %{} + end + + test "fee_type_column_index is nil when no fee-type column present" do + assert {:ok, %{fee_type_column_index: nil}} = HeaderMapper.build_maps(["email"], []) + end + + test "detects groups and fee-type columns together" do + headers = ["email", "Gruppen", "Beitragsart"] + + assert {:ok, result} = HeaderMapper.build_maps(headers, []) + + assert result.groups_column_index == 1 + assert result.fee_type_column_index == 2 + assert result.member[:email] == 0 + assert result.custom == %{} + assert result.unknown == [] + end + end + + describe "build_maps/2 fee-status ignore property" do + property "every fee-status variant is ignored, never member or custom" do + check all( + variant <- + StreamData.member_of([ + "Membership Fee Status", + "membership_fee_status", + "Mitgliedsbeitragsstatus", + "Bezahlstatus", + " Bezahlstatus ", + "BEZAHLSTATUS" + ]) + ) do + custom_fields = [%{id: "cf1", name: variant}] + + assert {:ok, result} = HeaderMapper.build_maps(["email", variant], custom_fields) + + assert result.ignored == [1] + assert result.custom == %{} + refute Map.has_key?(result.member, :bezahlstatus) + end + end + end end From aaffd7b91c2330611eb951c3923c356a65b37288 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 3 Jun 2026 00:06:34 +0000 Subject: [PATCH 20/47] chore(deps): update postgres docker tag to v18.4 --- docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 37f9552..98d4053 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -33,7 +33,7 @@ services: restart: unless-stopped db-prod: - image: postgres:18.3-alpine + image: postgres:18.4-alpine container_name: mv-prod-db environment: POSTGRES_USER: postgres diff --git a/docker-compose.yml b/docker-compose.yml index 01a0bd2..aeb16ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ networks: services: db: - image: postgres:18.3-alpine + image: postgres:18.4-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres From 8429fb2b9ce0fedaf37ccb16115cf8f69039b638 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 3 Jun 2026 00:06:45 +0000 Subject: [PATCH 21/47] chore(deps): update mix dependencies to v1 --- mix.exs | 4 ++-- mix.lock | 36 +++++++++++++++++++----------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/mix.exs b/mix.exs index a510a7e..fa31c04 100644 --- a/mix.exs +++ b/mix.exs @@ -39,8 +39,8 @@ defmodule Mv.MixProject do [ {:tidewave, "~> 0.5", only: [:dev]}, {:sourceror, "~> 1.8", only: [:dev, :test]}, - {:live_debugger, "~> 0.8", only: [:dev]}, - {:ash_admin, "~> 0.14"}, + {:live_debugger, "~> 1.0", only: [:dev]}, + {:ash_admin, "~> 1.0"}, {:ash_postgres, "~> 2.0"}, {:ash_phoenix, "~> 2.0"}, {:ash, "~> 3.0"}, diff --git a/mix.lock b/mix.lock index 7dd592f..e39ebbc 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,6 @@ %{ - "ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"}, - "ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"}, + "ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"}, + "ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"}, "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"}, @@ -11,18 +11,18 @@ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, + "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, + "cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, - "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, + "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, - "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, @@ -32,10 +32,11 @@ "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, + "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, @@ -44,7 +45,7 @@ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, + "igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"}, "imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, @@ -52,22 +53,23 @@ "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "live_debugger": {:hex, :live_debugger, "0.8.0", "02545b5accdf42f48aa9bddabdd7574ddf532d5aa0cb0270d7e35031bc184286", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4ecdec3c4267e665afd2266e3cb86239dd457f8c8fc4e63de6e150a2a7665920"}, + "live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"}, "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"}, + "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, @@ -78,15 +80,15 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, - "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, - "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, + "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"}, - "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, + "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, @@ -96,13 +98,13 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, } From a4a34cab3a4b4ce366d84ef000421f8ab6bc443c Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:10:33 +0200 Subject: [PATCH 22/47] feat(import): resolve import group and fee-type names against existing records --- lib/mv/membership/import/column_resolver.ex | 258 ++++++++++++++++++ .../import/column_resolver_query_test.exs | 72 +++++ .../import/column_resolver_test.exs | 227 +++++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 lib/mv/membership/import/column_resolver.ex create mode 100644 test/mv/membership/import/column_resolver_query_test.exs create mode 100644 test/mv/membership/import/column_resolver_test.exs diff --git a/lib/mv/membership/import/column_resolver.ex b/lib/mv/membership/import/column_resolver.ex new file mode 100644 index 0000000..2edb540 --- /dev/null +++ b/lib/mv/membership/import/column_resolver.ex @@ -0,0 +1,258 @@ +defmodule Mv.Membership.Import.ColumnResolver do + @moduledoc """ + Read-only resolution of CSV import columns against the database. + + Given the `HeaderMapper.build_maps/2` result, the raw numbered rows, and an + actor, `resolve/3` determines: + + - which group names in the groups column already exist (`groups_found`) and + which would have to be created (`groups_to_create`); + - a small set of preview rows for the mapping preview UI. + + No database writes happen here; the resolver only reads. Group creation and + member-group assignment happen during processing via `create_or_find_group/3`. + + This module has no Phoenix or web dependencies. + """ + + require Logger + + alias Mv.Membership.Import.HeaderMapper + + @preview_row_limit 3 + + @type numbered_row :: {pos_integer(), [String.t()]} + + @type resolution :: %{ + groups_found: [%{id: String.t(), name: String.t()}], + groups_to_create: [String.t()], + fee_type_map: %{String.t() => String.t()}, + fee_type_warnings: [String.t()], + has_empty_fee_type_cells?: boolean(), + preview_rows: [[String.t()]] + } + + @doc """ + Resolves the group and fee-type columns of an import against the database and + extracts preview rows. + + Returns a map with `:groups_found`, `:groups_to_create`, `:fee_type_map`, + `:fee_type_warnings`, `:has_empty_fee_type_cells?`, and `:preview_rows`. + """ + @spec resolve(map(), [numbered_row()], term()) :: resolution() + def resolve(header_maps, rows, actor) do + %{ + groups_found: groups_found, + groups_to_create: groups_to_create + } = resolve_groups(header_maps, rows, actor) + + %{ + fee_type_map: fee_type_map, + fee_type_warnings: fee_type_warnings, + has_empty_fee_type_cells?: has_empty_fee_type_cells? + } = resolve_fee_types(header_maps, rows, actor) + + %{ + groups_found: groups_found, + groups_to_create: groups_to_create, + fee_type_map: fee_type_map, + fee_type_warnings: fee_type_warnings, + has_empty_fee_type_cells?: has_empty_fee_type_cells?, + preview_rows: preview_rows(rows) + } + end + + defp resolve_groups(%{groups_column_index: nil}, _rows, _actor) do + %{groups_found: [], groups_to_create: []} + end + + defp resolve_groups(%{groups_column_index: index}, rows, actor) do + existing_groups = list_groups(actor) + lookup = build_group_lookup(existing_groups) + + names = unique_group_names(rows, index) + + {found, to_create} = + Enum.reduce(names, {[], []}, fn name, {found, to_create} -> + case Map.get(lookup, normalize_name(name)) do + nil -> {found, [name | to_create]} + group -> {[%{id: group.id, name: group.name} | found], to_create} + end + end) + + %{groups_found: Enum.reverse(found), groups_to_create: Enum.reverse(to_create)} + end + + defp resolve_fee_types(%{fee_type_column_index: nil}, _rows, _actor) do + %{fee_type_map: %{}, fee_type_warnings: [], has_empty_fee_type_cells?: false} + end + + defp resolve_fee_types(%{fee_type_column_index: index}, rows, actor) do + lookup = build_fee_type_lookup(actor) + + cells = Enum.map(rows, fn {_line, values} -> Enum.at(values, index) end) + + has_empty? = Enum.any?(cells, &blank?/1) + + {fee_type_map, warnings} = + cells + |> Enum.reject(&blank?/1) + |> Enum.uniq_by(&normalize_fee_type_name/1) + |> Enum.reduce({%{}, []}, fn name, {map, warnings} -> + case Map.get(lookup, normalize_fee_type_name(name)) do + nil -> {map, [String.trim(name) | warnings]} + id -> {Map.put(map, normalize_fee_type_name(name), id), warnings} + end + end) + + %{ + fee_type_map: fee_type_map, + fee_type_warnings: Enum.reverse(warnings), + has_empty_fee_type_cells?: has_empty? + } + end + + @doc """ + Normalizes a fee-type name using the same rules as CSV header normalization + (trim, lowercase, transliterate, drop hyphens and whitespace). + """ + @spec normalize_fee_type_name(String.t() | nil) :: String.t() + def normalize_fee_type_name(name) when is_binary(name), do: HeaderMapper.normalize_header(name) + def normalize_fee_type_name(_), do: "" + + defp build_fee_type_lookup(actor) do + actor + |> list_fee_types() + |> Enum.reduce(%{}, fn fee_type, acc -> + normalized = normalize_fee_type_name(fee_type.name) + + if Map.has_key?(acc, normalized) do + Logger.warning( + "Multiple membership fee types normalize to #{inspect(normalized)}; using the first match for CSV import." + ) + + acc + else + Map.put(acc, normalized, fee_type.id) + end + end) + end + + defp list_fee_types(actor) do + Mv.MembershipFees.list_membership_fee_types!(actor: actor) + end + + defp blank?(nil), do: true + defp blank?(value) when is_binary(value), do: String.trim(value) == "" + defp blank?(_), do: false + + @doc """ + Finds an existing group by name (case-insensitive) or creates it. + + Looks first in the pre-fetched `groups` list, then in the database (to catch + groups created earlier in the same import), and only creates a new group when + none is found. This keeps group resolution idempotent across re-imports. + """ + @spec create_or_find_group(String.t(), [Mv.Membership.Group.t()], term()) :: + {:ok, Mv.Membership.Group.t()} | {:error, term()} + def create_or_find_group(name, groups, actor) when is_binary(name) do + trimmed = String.trim(name) + normalized = normalize_name(trimmed) + + case find_group_in_list(groups, normalized) do + nil -> find_or_create_group(trimmed, normalized, actor) + group -> {:ok, group} + end + end + + defp find_group_in_list(groups, normalized) do + Enum.find(groups, fn group -> normalize_name(group.name) == normalized end) + end + + defp find_or_create_group(trimmed, normalized, actor) do + case fetch_group_by_normalized_name(normalized, actor) do + nil -> create_group(trimmed, normalized, actor) + group -> {:ok, group} + end + end + + # Normalizes the Ash code-interface return to a two-shape result. + # + # On a create failure the group may have been created concurrently by another + # import session between our read and our write (the DB unique index is the + # final arbiter, and the name validation is fail-open). Re-fetch by normalized + # name and link to the existing group rather than failing the row. + defp create_group(name, normalized, actor) do + case Mv.Membership.create_group(%{name: name}, actor: actor) do + {:ok, %Mv.Membership.Group{} = group} -> + {:ok, group} + + {:error, reason} -> + case fetch_group_by_normalized_name(normalized, actor) do + nil -> {:error, reason} + group -> {:ok, group} + end + end + end + + # Fetches a single group by case-insensitive name using a name-filtered query + # rather than reading the whole groups table. `normalized` is the trimmed, + # lower-cased name; the DB comparison uses LOWER(name) consistent with the + # Group resource's case-insensitive uniqueness constraint. + defp fetch_group_by_normalized_name(normalized, actor) do + require Ash.Query + + Mv.Membership.Group + |> Ash.Query.filter(fragment("LOWER(?) = ?", name, ^normalized)) + |> Ash.read(actor: actor, domain: Mv.Membership) + |> case do + {:ok, [group | _]} -> group + _ -> nil + end + end + + @doc """ + Splits a raw groups-cell value into trimmed, non-empty group names. + """ + @spec split_group_names(String.t() | nil) :: [String.t()] + def split_group_names(nil), do: [] + + def split_group_names(cell) when is_binary(cell) do + cell + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp unique_group_names(rows, index) do + rows + |> Enum.flat_map(fn {_line, values} -> + values + |> Enum.at(index) + |> split_group_names() + end) + |> Enum.uniq_by(&normalize_name/1) + end + + defp preview_rows(rows) do + rows + |> Enum.take(@preview_row_limit) + |> Enum.map(fn {_line, values} -> values end) + end + + defp list_groups(actor) do + Mv.Membership.list_groups!(actor: actor) + end + + defp build_group_lookup(groups) do + Enum.reduce(groups, %{}, fn group, acc -> + Map.put(acc, normalize_name(group.name), group) + end) + end + + # Case-insensitive comparison consistent with the Group resource's + # case-insensitive name uniqueness. + defp normalize_name(name) when is_binary(name) do + name |> String.trim() |> String.downcase() + end +end diff --git a/test/mv/membership/import/column_resolver_query_test.exs b/test/mv/membership/import/column_resolver_query_test.exs new file mode 100644 index 0000000..7ac1fee --- /dev/null +++ b/test/mv/membership/import/column_resolver_query_test.exs @@ -0,0 +1,72 @@ +defmodule Mv.Membership.Import.ColumnResolverQueryTest do + # async: false — attaches a global telemetry handler to inspect emitted SQL. + use Mv.DataCase, async: false + + alias Mv.Membership.Import.ColumnResolver + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + describe "create_or_find_group/3 group lookup is name-filtered (no full-table scan)" do + test "resolving a new name absent from the snapshot queries by name, not the whole table", + %{actor: actor} do + # Populate the table so a full-table read would be costly and observable. + for n <- 1..20, do: Mv.Fixtures.group_fixture(%{name: "Existing #{n}"}) + + queries = + capture_group_select_queries(fn -> + # The name is absent from the (empty) snapshot, forcing a DB lookup + # before the create attempt. That lookup must filter by name. + assert {:ok, group} = ColumnResolver.create_or_find_group("New One", [], actor) + assert group.name == "New One" + end) + + # No SELECT against the groups table issued during resolution may be an + # unfiltered full-table scan. The pre-create existence check must filter by + # name (carry a WHERE predicate). + refute Enum.any?(queries, &unfiltered_groups_select?/1), + "expected no unfiltered groups table scan, got:\n#{Enum.join(queries, "\n")}" + end + end + + defp capture_group_select_queries(fun) do + test_pid = self() + handler_id = "test-group-query-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler_id, + [:mv, :repo, :query], + fn _event, _measurements, metadata, _config -> + sql = metadata[:query] || "" + + if String.contains?(sql, "SELECT") and String.contains?(sql, "\"groups\"") do + send(test_pid, {:group_query, sql}) + end + end, + nil + ) + + try do + fun.() + after + :telemetry.detach(handler_id) + end + + collect_group_queries([]) + end + + defp collect_group_queries(acc) do + receive do + {:group_query, sql} -> collect_group_queries([sql | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + # An unfiltered groups SELECT reads the whole table: it selects FROM "groups" + # with no WHERE clause at all. A name-filtered lookup carries a WHERE predicate. + defp unfiltered_groups_select?(sql) do + String.contains?(sql, "FROM \"groups\"") and not String.contains?(sql, "WHERE") + end +end diff --git a/test/mv/membership/import/column_resolver_test.exs b/test/mv/membership/import/column_resolver_test.exs new file mode 100644 index 0000000..43b4288 --- /dev/null +++ b/test/mv/membership/import/column_resolver_test.exs @@ -0,0 +1,227 @@ +defmodule Mv.Membership.Import.ColumnResolverTest do + use Mv.DataCase, async: true + use ExUnitProperties + + alias Mv.Membership.Import.ColumnResolver + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp fee_type_fixture(name, actor) do + {:ok, fee_type} = + Mv.MembershipFees.create_membership_fee_type( + %{name: name, amount: Decimal.new("10.00"), interval: :yearly}, + actor: actor + ) + + fee_type + end + + defp header_maps(overrides) do + Map.merge( + %{ + member: %{email: 0}, + custom: %{}, + unknown: [], + ignored: [], + groups_column_index: nil, + fee_type_column_index: nil + }, + overrides + ) + end + + describe "resolve/3 group classification" do + test "splits group names into found (existing) and to_create (missing)", %{actor: actor} do + existing = Mv.Fixtures.group_fixture(%{name: "Orchester"}) + + maps = header_maps(%{member: %{email: 0}, groups_column_index: 1}) + + rows = [ + {2, ["a@example.com", "Orchester"]}, + {3, ["b@example.com", "Neues Ensemble"]} + ] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert Enum.any?(result.groups_found, &(&1.name == "Orchester" and &1.id == existing.id)) + assert "Neues Ensemble" in result.groups_to_create + refute "Orchester" in result.groups_to_create + end + + test "groups_found and groups_to_create are empty when no groups column", %{actor: actor} do + maps = header_maps(%{}) + rows = [{2, ["a@example.com"]}] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert result.groups_found == [] + assert result.groups_to_create == [] + end + end + + describe "resolve/3 preview rows" do + test "returns up to 3 preview rows", %{actor: actor} do + maps = header_maps(%{}) + + rows = [ + {2, ["a@example.com"]}, + {3, ["b@example.com"]}, + {4, ["c@example.com"]}, + {5, ["d@example.com"]} + ] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert length(result.preview_rows) == 3 + assert result.preview_rows == [["a@example.com"], ["b@example.com"], ["c@example.com"]] + end + + test "returns fewer preview rows when file has fewer data rows", %{actor: actor} do + maps = header_maps(%{}) + rows = [{2, ["a@example.com"]}] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert result.preview_rows == [["a@example.com"]] + end + end + + describe "resolve/3 fee-type resolution" do + test "maps known fee-type names to their id by normalized name", %{actor: actor} do + standard = fee_type_fixture("Standard", actor) + + maps = header_maps(%{fee_type_column_index: 1}) + rows = [{2, ["a@example.com", "Standard"]}] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert result.fee_type_map["standard"] == standard.id + assert result.fee_type_warnings == [] + end + + test "records a warning for an unknown fee-type name", %{actor: actor} do + maps = header_maps(%{fee_type_column_index: 1}) + rows = [{2, ["a@example.com", "Nonexistent Type"]}] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert "Nonexistent Type" in result.fee_type_warnings + end + + test "sets has_empty_fee_type_cells? when a fee-type cell is blank", %{actor: actor} do + fee_type_fixture("Standard", actor) + + maps = header_maps(%{fee_type_column_index: 1}) + + rows = [ + {2, ["a@example.com", "Standard"]}, + {3, ["b@example.com", " "]} + ] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert result.has_empty_fee_type_cells? == true + end + + test "has_empty_fee_type_cells? is false when all cells filled", %{actor: actor} do + fee_type_fixture("Standard", actor) + + maps = header_maps(%{fee_type_column_index: 1}) + rows = [{2, ["a@example.com", "Standard"]}] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert result.has_empty_fee_type_cells? == false + end + + test "fee-type resolution defaults are empty when no fee-type column", %{actor: actor} do + maps = header_maps(%{}) + rows = [{2, ["a@example.com"]}] + + result = ColumnResolver.resolve(maps, rows, actor) + + assert result.fee_type_map == %{} + assert result.fee_type_warnings == [] + assert result.has_empty_fee_type_cells? == false + end + end + + describe "create_or_find_group/3" do + test "creates a new group when none exists", %{actor: actor} do + assert {:ok, group} = ColumnResolver.create_or_find_group("Brand New Group", [], actor) + assert group.name == "Brand New Group" + end + + test "returns the existing group from the pre-fetched list without creating", %{actor: actor} do + existing = Mv.Fixtures.group_fixture(%{name: "Existing Group"}) + before_count = length(Mv.Membership.list_groups!(actor: actor)) + + assert {:ok, group} = + ColumnResolver.create_or_find_group("Existing Group", [existing], actor) + + assert group.id == existing.id + assert length(Mv.Membership.list_groups!(actor: actor)) == before_count + end + + test "resolves to a group created concurrently after the snapshot was taken", + %{actor: actor} do + # Simulates a concurrent import session: the group name is absent from the + # caller's pre-fetched snapshot, but the group now exists in the DB. The + # resolver must link to the existing group, never error or duplicate it. + stale_snapshot = [] + _concurrently_created = Mv.Fixtures.group_fixture(%{name: "Concurrent Group"}) + before_count = length(Mv.Membership.list_groups!(actor: actor)) + + assert {:ok, group} = + ColumnResolver.create_or_find_group("Concurrent Group", stale_snapshot, actor) + + assert group.name == "Concurrent Group" + assert length(Mv.Membership.list_groups!(actor: actor)) == before_count + end + + property "is idempotent: same names never create duplicate groups", %{actor: actor} do + check all( + names <- + StreamData.list_of( + StreamData.string(:alphanumeric, min_length: 1, max_length: 20), + min_length: 1, + max_length: 5 + ), + max_runs: 25 + ) do + names = Enum.map(names, &("grp-" <> &1)) + + existing = Mv.Membership.list_groups!(actor: actor) + first_ids = resolve_all(names, existing, actor) + + existing_after = Mv.Membership.list_groups!(actor: actor) + second_ids = resolve_all(names, existing_after, actor) + + # Same name always resolves to the same group id across both passes. + assert first_ids == second_ids + + # No duplicate groups exist for any of the names (case-insensitive). + all_groups = Mv.Membership.list_groups!(actor: actor) + + for name <- Enum.uniq_by(names, &String.downcase/1) do + matching = + Enum.filter(all_groups, fn g -> + String.downcase(g.name) == String.downcase(name) + end) + + assert length(matching) == 1 + end + end + end + end + + defp resolve_all(names, existing, actor) do + Enum.map(names, fn name -> + {:ok, group} = ColumnResolver.create_or_find_group(name, existing, actor) + {String.downcase(name), group.id} + end) + |> Map.new() + end +end From 00e1624ee4cf13828538843efeb1ee7e3f134463 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:15:54 +0200 Subject: [PATCH 23/47] feat(import): assign groups and fee types to imported members, creating missing groups --- lib/mv/membership/import/header_mapper.ex | 15 +- lib/mv/membership/import/member_csv.ex | 265 +++++++++++++++--- priv/gettext/de/LC_MESSAGES/default.po | 13 +- priv/gettext/default.pot | 10 + priv/gettext/en/LC_MESSAGES/default.po | 13 +- test/mv/membership/import/member_csv_test.exs | 252 +++++++++++++++++ 6 files changed, 517 insertions(+), 51 deletions(-) diff --git a/lib/mv/membership/import/header_mapper.ex b/lib/mv/membership/import/header_mapper.ex index 3047944..eb8bb04 100644 --- a/lib/mv/membership/import/header_mapper.ex +++ b/lib/mv/membership/import/header_mapper.ex @@ -29,12 +29,21 @@ defmodule Mv.Membership.Import.HeaderMapper do Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum"). + ## Special columns + + - **groups** – Many-to-many relationship (through member_groups). Recognized via the + `groups_column_index` key (headers `Groups`, `Gruppen`, `Gruppe`). Comma-separated + names are resolved during processing; missing groups are auto-created. + - **membership_fee_type** – Recognized via the `fee_type_column_index` key (headers + `Fee Type`, `fee_type`, `membership_fee_type`, `Beitragsart`). Names are matched to + existing fee types; unknown names fall back to the default fee type. + ## Fields not supported for import - **membership_fee_status** – Computed (calculation from membership fee cycles). Not stored; - cannot be set via CSV. Export can include it. - - **groups** – Many-to-many relationship (through member_groups). Import would require - resolving group names/slugs to IDs and creating associations; not in current import scope. + cannot be set via CSV. Export can include it. Fee-status header variants + (`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) are explicitly + placed in the `ignored` list and never mapped. ## Custom Field Detection diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index dda1d04..d0ab74b 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -6,7 +6,7 @@ defmodule Mv.Membership.Import.MemberCSV do This module provides the core API for CSV member import functionality: - `prepare/2` - Parses and validates CSV content, returns import state - - `process_chunk/3` - Processes a chunk of rows and creates members + - `process_chunk/4` - Processes a chunk of rows and creates members ## Error Handling @@ -22,10 +22,18 @@ defmodule Mv.Membership.Import.MemberCSV do - `column_map` - Map of canonical field names to column indices - `custom_field_map` - Map of custom field names to column indices - `warnings` - List of warning messages (e.g., unknown custom field columns) + - `headers` - The raw CSV header row + - `ignored` - Header names of ignored (fee-status) columns + - `groups_column_index` / `fee_type_column_index` - Indices for resolved columns (or nil) + - `groups_found` / `groups_to_create` - Existing and to-be-created groups from the preview + - `fee_type_map` - Normalized fee-type name to id, for matched fee types + - `fee_type_warnings` - Unmatched fee-type names surfaced in the preview + - `has_empty_fee_type_cells?` - Whether any fee-type cell is blank (default applies) + - `preview_rows` - Up to 3 sample data rows for the mapping preview ## Chunk Results - The `chunk_result` returned by `process_chunk/3` contains: + The `chunk_result` returned by `process_chunk/4` contains: - `inserted` - Number of successfully created members - `failed` - Number of failed member creations - `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import) @@ -37,7 +45,9 @@ defmodule Mv.Membership.Import.MemberCSV do # Process first chunk chunk = Enum.at(import_state.chunks, 0) - {:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map) + + {:ok, result} = + MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, []) """ defmodule Error do @@ -66,16 +76,28 @@ defmodule Mv.Membership.Import.MemberCSV do custom_field_lookup: %{ String.t() => %{id: String.t(), value_type: atom(), name: String.t()} }, - warnings: list(String.t()) + warnings: list(String.t()), + headers: list(String.t()), + ignored: list(String.t()), + groups_column_index: non_neg_integer() | nil, + fee_type_column_index: non_neg_integer() | nil, + groups_found: list(%{id: String.t(), name: String.t()}), + groups_to_create: list(String.t()), + fee_type_map: %{String.t() => String.t()}, + fee_type_warnings: list(String.t()), + has_empty_fee_type_cells?: boolean(), + preview_rows: list(list(String.t())) } @type chunk_result :: %{ inserted: non_neg_integer(), failed: non_neg_integer(), errors: list(Error.t()), - errors_truncated?: boolean() + errors_truncated?: boolean(), + warnings: list(String.t()) } + alias Mv.Membership.Import.ColumnResolver alias Mv.Membership.Import.CsvParser alias Mv.Membership.Import.HeaderMapper @@ -139,13 +161,27 @@ defmodule Mv.Membership.Import.MemberCSV do # Build custom field lookup for efficient value processing custom_field_lookup = build_custom_field_lookup(custom_fields) + # Resolve DB-backed columns (groups, fee types) read-only for the preview. + resolution = ColumnResolver.resolve(maps, rows, actor) + ignored_headers = Enum.map(maps.ignored, &Enum.at(headers, &1)) + {:ok, %{ chunks: chunks, column_map: maps.member, custom_field_map: maps.custom, custom_field_lookup: custom_field_lookup, - warnings: warnings + warnings: warnings, + headers: headers, + ignored: ignored_headers, + groups_column_index: maps.groups_column_index, + fee_type_column_index: maps.fee_type_column_index, + groups_found: resolution.groups_found, + groups_to_create: resolution.groups_to_create, + fee_type_map: resolution.fee_type_map, + fee_type_warnings: resolution.fee_type_warnings, + has_empty_fee_type_cells?: resolution.has_empty_fee_type_cells?, + preview_rows: resolution.preview_rows }} end end @@ -180,7 +216,7 @@ defmodule Mv.Membership.Import.MemberCSV do end) case HeaderMapper.build_maps(headers, custom_field_maps) do - {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} -> + {:ok, %{unknown: unknown} = maps} -> # Build warnings for unknown custom field columns warnings = unknown @@ -197,7 +233,7 @@ defmodule Mv.Membership.Import.MemberCSV do ) end) - {:ok, %{member: member_map, custom: custom_map}, warnings} + {:ok, maps, warnings} {:error, reason} -> {:error, reason} @@ -250,9 +286,20 @@ defmodule Mv.Membership.Import.MemberCSV do Map.put(acc, custom_field_id, value) end) - %{member: member_map, custom: custom_map} + %{ + member: member_map, + custom: custom_map, + fee_type: cell_at(row_tuple, tuple_size, maps.fee_type_column_index), + groups: cell_at(row_tuple, tuple_size, maps.groups_column_index) + } end + # Returns the raw cell at the given index, or nil if the column is absent. + defp cell_at(_row_tuple, _size, nil), do: nil + + defp cell_at(row_tuple, size, index) when index < size, do: elem(row_tuple, index) + defp cell_at(_row_tuple, _size, _index), do: "" + @doc """ Processes a chunk of CSV rows and creates members. @@ -268,12 +315,18 @@ defmodule Mv.Membership.Import.MemberCSV do - `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where: - `csv_line_number` - Physical line number in CSV (1-based) - `row_map` - Map with `:member` and `:custom` keys containing field values - - `column_map` - Map of canonical field names (atoms) to column indices (for reference) - - `custom_field_map` - Map of custom field IDs (strings) to column indices (for reference) + - `column_map` - Unused; kept for backward-compatible call sites. Field values are + read from each row's pre-built `:member`/`:custom` maps, not from this argument. + - `custom_field_map` - Unused; kept for backward-compatible call sites (see above). - `opts` - Optional keyword list for processing options: - `:custom_field_lookup` - Map of custom field IDs to metadata (default: `%{}`) - `:existing_error_count` - Number of errors already collected in previous chunks (default: `0`) - `:max_errors` - Maximum number of errors to collect per import overall (default: `50`) + - `:actor` - Actor used for all writes (default: the system actor) + - `:fee_type_map` - Map of normalized fee-type name to fee-type id, used to resolve + each row's fee-type cell (default: `%{}`) + - `:groups_found` - List of pre-fetched `Group` structs seeding in-memory group + resolution; the snapshot grows as groups are auto-created (default: `[]`) ## Error Capping @@ -312,27 +365,49 @@ defmodule Mv.Membership.Import.MemberCSV do existing_error_count = Keyword.get(opts, :existing_error_count, 0) max_errors = Keyword.get(opts, :max_errors, @default_max_errors) actor = Keyword.get(opts, :actor, SystemActor.get_system_actor()) + fee_type_map = Keyword.get(opts, :fee_type_map, %{}) + groups_found = Keyword.get(opts, :groups_found, []) - {inserted, failed, errors, _collected_error_count, truncated?} = - Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map}, - {acc_inserted, acc_failed, - acc_errors, acc_error_count, - acc_truncated?} -> + base_row_opts = %{ + custom_field_lookup: custom_field_lookup, + fee_type_map: fee_type_map, + actor: actor + } + + {inserted, failed, errors, _collected_error_count, truncated?, warnings, _groups_acc} = + Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false, [], groups_found}, fn {line_number, + row_map}, + {acc_inserted, + acc_failed, + acc_errors, + acc_error_count, + acc_truncated?, + acc_warnings, + acc_groups} -> current_error_count = existing_error_count + acc_error_count + row_opts = Map.put(base_row_opts, :groups_found, acc_groups) - case process_row(row_map, line_number, custom_field_lookup, actor) do - {:ok, _member} -> - update_inserted( - {acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?} - ) + case process_row(row_map, line_number, row_opts) do + {:ok, _member, row_warnings, new_groups} -> + {new_inserted, new_failed, new_errors, new_error_count, new_truncated?} = + update_inserted( + {acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?} + ) - {:error, error} -> - handle_row_error( - {acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}, - error, - current_error_count, - max_errors - ) + {new_inserted, new_failed, new_errors, new_error_count, new_truncated?, + acc_warnings ++ row_warnings, new_groups} + + {:error, error, new_groups} -> + {new_inserted, new_failed, new_errors, new_error_count, new_truncated?} = + handle_row_error( + {acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}, + error, + current_error_count, + max_errors + ) + + {new_inserted, new_failed, new_errors, new_error_count, new_truncated?, acc_warnings, + new_groups} end end) @@ -341,7 +416,8 @@ defmodule Mv.Membership.Import.MemberCSV do inserted: inserted, failed: failed, errors: Enum.reverse(errors), - errors_truncated?: truncated? + errors_truncated?: truncated?, + warnings: warnings }} end @@ -505,18 +581,27 @@ defmodule Mv.Membership.Import.MemberCSV do defp gettext_error_message(_), do: gettext("Email is invalid.") - # Processes a single row and creates member with custom field values + # Processes a single row and creates member with custom field values. + # On success returns {:ok, member, warnings, groups}; warnings carry non-fatal + # notices such as an unresolved fee-type name. The returned groups list is the + # accumulated in-memory group snapshot (seeded from the chunk, grown with any + # group created while linking this row) so later rows reuse it instead of + # re-reading the whole Group table per row. defp process_row( row_map, line_number, - custom_field_lookup, - actor + %{ + custom_field_lookup: custom_field_lookup, + fee_type_map: fee_type_map, + groups_found: groups_found, + actor: actor + } = _row_opts ) do # Validate row before database insertion case validate_row(row_map, line_number, []) do {:error, error} -> # Return validation error immediately, no DB insert attempted - {:error, error} + {:error, error, groups_found} {:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} -> # Prepare custom field values for Ash @@ -524,20 +609,119 @@ defmodule Mv.Membership.Import.MemberCSV do {:error, validation_errors} -> # Custom field validation errors - return first error first_error = List.first(validation_errors) - {:error, %Error{csv_line_number: line_number, field: nil, message: first_error}} + + {:error, %Error{csv_line_number: line_number, field: nil, message: first_error}, + groups_found} {:ok, custom_field_values} -> - create_member_with_custom_fields( - trimmed_member_attrs, + {fee_attrs, warnings} = + resolve_fee_type_attrs(Map.get(row_map, :fee_type), fee_type_map) + + create_member_and_assign_groups( + Map.merge(trimmed_member_attrs, fee_attrs), custom_field_values, + Map.get(row_map, :groups), + groups_found, line_number, - actor + actor, + warnings ) end end rescue e -> - {:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}} + {:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}, + groups_found} + end + + # Creates the member, then assigns groups as a post-creation step. A group + # assignment failure fails the row (the member was already created, but the + # row is reported as failed so the operator can act on it). + defp create_member_and_assign_groups( + member_attrs, + custom_field_values, + groups_cell, + groups_found, + line_number, + actor, + warnings + ) do + case create_member_with_custom_fields( + member_attrs, + custom_field_values, + line_number, + actor, + warnings + ) do + {:ok, member, member_warnings} -> + assign_groups(member, groups_cell, groups_found, line_number, actor, member_warnings) + + {:error, error} -> + {:error, error, groups_found} + end + end + + # Assigns the member to all groups listed in the cell, creating missing groups. + # Returns the (possibly grown) group snapshot so the caller can reuse it. + defp assign_groups(member, groups_cell, groups_found, line_number, actor, warnings) do + names = ColumnResolver.split_group_names(groups_cell) + + Enum.reduce_while(names, {:ok, member, warnings, groups_found}, fn name, + {:ok, _m, _w, acc_groups} -> + case link_member_to_group(member, name, acc_groups, actor) do + {:ok, group} -> + {:cont, {:ok, member, warnings, add_group(acc_groups, group)}} + + {:error, reason} -> + {:halt, + {:error, + %Error{ + csv_line_number: line_number, + field: nil, + message: gettext("Group assignment failed: %{reason}", reason: inspect(reason)) + }, acc_groups}} + end + end) + end + + defp add_group(groups, group) do + if Enum.any?(groups, &(&1.id == group.id)), do: groups, else: [group | groups] + end + + defp link_member_to_group(member, name, groups_found, actor) do + with {:ok, group} <- ColumnResolver.create_or_find_group(name, groups_found, actor), + {:ok, _member_group} <- + Mv.Membership.create_member_group( + %{member_id: member.id, group_id: group.id}, + actor: actor + ) do + {:ok, group} + end + end + + # Resolves the fee-type cell into member attrs plus optional warnings. + # Empty cell -> default fee type (SetDefaultMembershipFeeType), no warning. + # Matched name -> membership_fee_type_id attr. + # Unmatched name -> no attr (default applies), warning naming the value. + defp resolve_fee_type_attrs(nil, _fee_type_map), do: {%{}, []} + + defp resolve_fee_type_attrs(cell, fee_type_map) when is_binary(cell) do + trimmed = String.trim(cell) + + if trimmed == "" do + {%{}, []} + else + case Map.get(fee_type_map, ColumnResolver.normalize_fee_type_name(trimmed)) do + nil -> + {%{}, + [ + gettext("Fee type '%{name}' not found; using the default fee type.", name: trimmed) + ]} + + fee_type_id -> + {%{membership_fee_type_id: fee_type_id}, []} + end + end end # Creates a member with custom field values, handling errors appropriately @@ -545,7 +729,8 @@ defmodule Mv.Membership.Import.MemberCSV do trimmed_member_attrs, custom_field_values, line_number, - actor + actor, + warnings ) do # Convert empty strings to nil for date fields so Ash accepts them member_attrs = sanitize_date_fields(trimmed_member_attrs) @@ -565,7 +750,7 @@ defmodule Mv.Membership.Import.MemberCSV do case Mv.Membership.create_member(final_attrs, actor: actor) do {:ok, member} -> - {:ok, member} + {:ok, member, warnings} {:error, %Ash.Error.Invalid{} = error} -> # Extract email from final_attrs for better error messages diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 81d91f7..9fa6cd4 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3968,7 +3968,12 @@ msgstr "Zeitraum" msgid "To" msgstr "Bis" -#~ #: lib/mv_web/live/group_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "No members selected." -#~ msgstr "Keine Mitglieder ausgewählt." +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Fee type '%{name}' not found; using the default fee type." +msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwendet." + +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Group assignment failed: %{reason}" +msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5e9abca..c961420 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3967,3 +3967,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "To" msgstr "" + +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Fee type '%{name}' not found; using the default fee type." +msgstr "" + +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Group assignment failed: %{reason}" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 1ae6a49..58aeead 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3968,7 +3968,12 @@ msgstr "" msgid "To" msgstr "" -#~ #: lib/mv_web/live/group_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "No members selected." -#~ msgstr "" +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Fee type '%{name}' not found; using the default fee type." +msgstr "" + +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Group assignment failed: %{reason}" +msgstr "" diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs index b4a099a..0701a92 100644 --- a/test/mv/membership/import/member_csv_test.exs +++ b/test/mv/membership/import/member_csv_test.exs @@ -1,5 +1,6 @@ defmodule Mv.Membership.Import.MemberCSVTest do use Mv.DataCase, async: true + use ExUnitProperties alias Mv.Membership.Import.MemberCSV @@ -899,4 +900,255 @@ defmodule Mv.Membership.Import.MemberCSVTest do assert import_state.chunks != [] end end + + describe "prepare/2 column resolution integration" do + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + test "exposes resolver output keys in import_state", %{actor: actor} do + csv_content = "email\njohn@example.com" + + assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor) + + assert Map.has_key?(import_state, :ignored) + assert Map.has_key?(import_state, :groups_to_create) + assert Map.has_key?(import_state, :fee_type_map) + assert Map.has_key?(import_state, :fee_type_warnings) + assert Map.has_key?(import_state, :has_empty_fee_type_cells?) + assert Map.has_key?(import_state, :preview_rows) + end + + test "fee-status column is reported as ignored, not as a custom field", %{actor: actor} do + {:ok, _custom_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{name: "Bezahlstatus", value_type: :string}) + |> Ash.create(actor: actor) + + csv_content = "email;Bezahlstatus\njohn@example.com;paid" + + assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor) + + assert import_state.ignored == ["Bezahlstatus"] + assert import_state.custom_field_map == %{} + end + + test "preview rows are limited to 3", %{actor: actor} do + csv_content = "email\na@example.com\nb@example.com\nc@example.com\nd@example.com" + + assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor) + + assert length(import_state.preview_rows) == 3 + end + end + + describe "process_chunk/4 fee-type assignment" do + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, fee_type} = + Mv.MembershipFees.create_membership_fee_type( + %{name: "Premium", amount: Decimal.new("25.00"), interval: :yearly}, + actor: actor + ) + + %{actor: actor, fee_type: fee_type} + end + + test "sets membership_fee_type_id when fee-type cell matches a known type", %{ + actor: actor, + fee_type: fee_type + } do + chunk = [ + {2, %{member: %{email: "fee-known@example.com"}, custom: %{}, fee_type: "Premium"}} + ] + + opts = [ + actor: actor, + fee_type_map: %{"premium" => fee_type.id} + ] + + assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts) + assert result.inserted == 1 + + member = + Mv.Membership.list_members!(actor: actor) + |> Enum.find(&(&1.email == "fee-known@example.com")) + + assert member.membership_fee_type_id == fee_type.id + end + + test "adds a warning when the fee-type name is unknown", %{actor: actor} do + chunk = [ + {2, %{member: %{email: "fee-unknown@example.com"}, custom: %{}, fee_type: "Ghost Type"}} + ] + + opts = [actor: actor, fee_type_map: %{}] + + assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts) + assert result.inserted == 1 + assert Enum.any?(result.warnings, &(&1 =~ "Ghost Type")) + end + + test "uses the default fee type when the fee-type cell is empty", %{ + actor: actor, + fee_type: fee_type + } do + {:ok, settings} = Mv.Membership.get_settings() + + {:ok, _settings} = + Mv.Membership.update_settings( + settings, + %{default_membership_fee_type_id: fee_type.id}, + actor: actor + ) + + chunk = [{2, %{member: %{email: "fee-empty@example.com"}, custom: %{}, fee_type: ""}}] + + opts = [actor: actor, fee_type_map: %{}] + + assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts) + assert result.inserted == 1 + + member = + Mv.Membership.list_members!(actor: actor) + |> Enum.find(&(&1.email == "fee-empty@example.com")) + + # Default fee type assigned via SetDefaultMembershipFeeType. + assert member.membership_fee_type_id == fee_type.id + end + end + + describe "process_chunk/4 group assignment" do + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp group_names_for(email, actor) do + member = + Mv.Membership.list_members!(actor: actor) + |> Enum.find(&(&1.email == email)) + + member = Ash.load!(member, :groups, actor: actor) + member.groups |> Enum.map(& &1.name) |> Enum.sort() + end + + test "assigns member to an existing group", %{actor: actor} do + existing = Mv.Fixtures.group_fixture(%{name: "Orchester"}) + + chunk = [ + {2, %{member: %{email: "g-existing@example.com"}, custom: %{}, groups: "Orchester"}} + ] + + opts = [actor: actor, groups_found: [existing]] + + assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts) + assert result.inserted == 1 + + assert group_names_for("g-existing@example.com", actor) == ["Orchester"] + + # No new group was created. + orchester = Enum.filter(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Orchester")) + assert length(orchester) == 1 + end + + test "auto-creates an unknown group and assigns the member", %{actor: actor} do + chunk = [ + {2, %{member: %{email: "g-new@example.com"}, custom: %{}, groups: "Frische Gruppe"}} + ] + + opts = [actor: actor, groups_found: []] + + assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts) + assert result.inserted == 1 + + assert group_names_for("g-new@example.com", actor) == ["Frische Gruppe"] + assert Enum.any?(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Frische Gruppe")) + end + + test "handles multiple comma-separated groups", %{actor: actor} do + chunk = [ + {2, %{member: %{email: "g-multi@example.com"}, custom: %{}, groups: "Orchester, Chor"}} + ] + + opts = [actor: actor, groups_found: []] + + assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts) + assert result.inserted == 1 + + assert group_names_for("g-multi@example.com", actor) == ["Chor", "Orchester"] + end + + test "does not re-read the group table once per row for a repeated novel name", + %{actor: actor} do + rows = + for i <- 1..10 do + {i + 1, + %{member: %{email: "g-nplus1-#{i}@example.com"}, custom: %{}, groups: "Shared Group"}} + end + + group_read_count = Agent.start_link(fn -> 0 end) |> elem(1) + test_pid = self() + + # process_chunk runs synchronously in this test process, so the telemetry + # handler (invoked in the query-executing process) sees self() == test_pid. + # Filtering on the pid keeps concurrent tests' group queries out of the count. + handler = fn _event, _measurements, metadata, _config -> + if self() == test_pid and metadata[:source] == "groups" and + is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do + Agent.update(group_read_count, &(&1 + 1)) + end + end + + handler_id = "test-group-read-counter-#{System.unique_integer([:positive])}" + :telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil) + + assert {:ok, %{inserted: 10}} = + MemberCSV.process_chunk(rows, %{email: 0}, %{}, actor: actor, groups_found: []) + + reads = Agent.get(group_read_count, & &1) + :telemetry.detach(handler_id) + + # The novel group is created on the first row and reused in memory for the + # remaining nine. Without accumulation each row triggers a fresh full-table + # read, scaling linearly with the row count. + assert reads <= 3, + "Expected the group table read at most a few times, got #{reads} reads for 10 rows (N+1)." + end + + test "empty groups cell leaves the member without group assignment", %{actor: actor} do + chunk = [{2, %{member: %{email: "g-empty@example.com"}, custom: %{}, groups: " "}}] + opts = [actor: actor, groups_found: []] + + assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts) + assert result.inserted == 1 + assert result.errors == [] + + assert group_names_for("g-empty@example.com", actor) == [] + end + + property "re-importing the same groups does not create duplicates", %{actor: actor} do + check all( + name <- StreamData.string(:alphanumeric, min_length: 1, max_length: 15), + max_runs: 15 + ) do + group_name = "dup-" <> name + email1 = "dup-#{System.unique_integer([:positive])}@example.com" + email2 = "dup-#{System.unique_integer([:positive])}@example.com" + opts = [actor: actor, groups_found: []] + + chunk1 = [{2, %{member: %{email: email1}, custom: %{}, groups: group_name}}] + chunk2 = [{2, %{member: %{email: email2}, custom: %{}, groups: group_name}}] + + assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, opts) + assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk2, %{email: 0}, %{}, opts) + + matching = + Mv.Membership.list_groups!(actor: actor) + |> Enum.filter(&(String.downcase(&1.name) == String.downcase(group_name))) + + assert length(matching) == 1 + end + end + end end From a93dd9d5350be73960eeeb8be593c8dbb26512d4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:21:36 +0200 Subject: [PATCH 24/47] feat(import): serve dynamic CSV import templates reflecting current custom fields --- .../controllers/import_template_controller.ex | 120 ++++++++++++++++++ lib/mv_web/live/import_live/components.ex | 12 +- lib/mv_web/router.ex | 4 + .../import_template_controller_test.exs | 104 +++++++++++++++ test/mv_web/live/import_live_test.exs | 11 +- 5 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 lib/mv_web/controllers/import_template_controller.ex create mode 100644 test/mv_web/controllers/import_template_controller_test.exs diff --git a/lib/mv_web/controllers/import_template_controller.ex b/lib/mv_web/controllers/import_template_controller.ex new file mode 100644 index 0000000..f040c7a --- /dev/null +++ b/lib/mv_web/controllers/import_template_controller.ex @@ -0,0 +1,120 @@ +defmodule MvWeb.ImportTemplateController do + @moduledoc """ + Serves CSV import templates generated on the fly from the current custom fields. + + Two actions provide an English (`en/2`) and a German (`de/2`) template. Each + template has a single header row listing the standard member columns followed + by every existing custom field name (exact match, as the import expects), plus + the importable groups and fee-type columns. A single placeholder example row is + included to illustrate the format. + + Both actions require the same authorization as the import page + (`can?(:create, Member)`); unauthorized requests are rejected. + """ + use MvWeb, :controller + + alias Mv.Authorization.Actor + alias Mv.Membership.Member + alias Mv.Membership.MembersCSV + alias MvWeb.Authorization + + # Standard member columns in template order, with their English and German headers + # and a placeholder example value. Groups and fee type are importable extras. + @columns [ + {"first name", "Vorname", "John", "Max"}, + {"last name", "Nachname", "Doe", "Mustermann"}, + {"email", "E-Mail", "john.doe@example.com", "max.mustermann@example.com"}, + {"country", "Land", "Germany", "Deutschland"}, + {"city", "Stadt", "Berlin", "Berlin"}, + {"street", "Straße", "Main Street", "Hauptstraße"}, + {"house number", "Hausnummer", "1a", "12"}, + {"postal_code", "PLZ", "12345", "10115"}, + {"join_date", "Beitrittsdatum", "2020-01-15", "2020-01-15"}, + {"exit_date", "Austrittsdatum", "", ""}, + {"notes", "Notizen", "", ""}, + {"membership_fee_start_date", "Beitragsbeginn", "", ""}, + {"Groups", "Gruppen", "", ""}, + {"Fee Type", "Beitragsart", "", ""} + ] + + @spec en(Plug.Conn.t(), map()) :: Plug.Conn.t() + def en(conn, _params) do + serve_template(conn, :en, "member_import_en.csv") + end + + @spec de(Plug.Conn.t(), map()) :: Plug.Conn.t() + def de(conn, _params) do + serve_template(conn, :de, "member_import_de.csv") + end + + defp serve_template(conn, locale, filename) do + actor = current_actor(conn) + + if Authorization.can?(actor, :create, Member) do + csv = build_csv(locale, actor) + + send_download(conn, {:binary, csv}, + filename: filename, + content_type: "text/csv; charset=utf-8" + ) + else + return_forbidden(conn) + end + end + + defp build_csv(locale, actor) do + custom_field_names = custom_field_names(actor) + + header = + Enum.map(@columns, &header_for(&1, locale)) ++ custom_field_names + + example = + Enum.map(@columns, &example_for(&1, locale)) ++ Enum.map(custom_field_names, fn _ -> "" end) + + [csv_row(header), csv_row(example)] + |> Enum.join("\n") + end + + defp header_for({en, _de, _ex_en, _ex_de}, :en), do: en + defp header_for({_en, de, _ex_en, _ex_de}, :de), do: de + + defp example_for({_en, _de, ex_en, _ex_de}, :en), do: ex_en + defp example_for({_en, _de, _ex_en, ex_de}, :de), do: ex_de + + defp custom_field_names(actor) do + Mv.Membership.list_custom_fields!(actor: actor) + |> Enum.map(& &1.name) + end + + # Serializes a row using the semicolon delimiter (the import auto-detects it), + # quoting any field that contains a delimiter, quote, or newline. + defp csv_row(fields) do + Enum.map_join(fields, ";", &escape_field/1) + end + + # Neutralizes spreadsheet formula triggers (the same guard the export writer + # applies) before RFC 4180 quoting, so a custom-field name like + # `=HYPERLINK(...)` is not evaluated when the template is opened. + defp escape_field(field) do + field = field |> to_string() |> MembersCSV.safe_cell() + + if String.contains?(field, [";", "\"", "\n", "\r"]) do + "\"" <> String.replace(field, "\"", "\"\"") <> "\"" + else + field + end + end + + defp current_actor(conn) do + conn.assigns[:current_user] + |> Actor.ensure_loaded() + end + + defp return_forbidden(conn) do + conn + |> put_status(403) + |> put_resp_content_type("application/json") + |> json(%{error: "Forbidden"}) + |> halt() + end +end diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 3bf10cb..eacc263 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -44,20 +44,12 @@ defmodule MvWeb.ImportLive.Components do

  • - <.link - href={~p"/templates/member_import_en.csv"} - download="member_import_en.csv" - class="link link-primary" - > + <.link href={~p"/admin/import/template/en"} class="link link-primary"> {gettext("English Template")}
  • - <.link - href={~p"/templates/member_import_de.csv"} - download="member_import_de.csv" - class="link link-primary" - > + <.link href={~p"/admin/import/template/de"} class="link link-primary"> {gettext("German Template")}
  • diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 64036c9..bc2ab30 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -102,6 +102,10 @@ defmodule MvWeb.Router do # Import (Admin only) live "/admin/import", ImportLive + # Dynamic CSV import templates (admin only; generated from current custom fields) + get "/admin/import/template/en", ImportTemplateController, :en + get "/admin/import/template/de", ImportTemplateController, :de + post "/members/export.csv", MemberExportController, :export post "/members/export.pdf", MemberPdfExportController, :export post "/set_locale", LocaleController, :set_locale diff --git a/test/mv_web/controllers/import_template_controller_test.exs b/test/mv_web/controllers/import_template_controller_test.exs new file mode 100644 index 0000000..5799938 --- /dev/null +++ b/test/mv_web/controllers/import_template_controller_test.exs @@ -0,0 +1,104 @@ +defmodule MvWeb.ImportTemplateControllerTest do + use MvWeb.ConnCase, async: true + + setup %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, custom_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{name: "Lieblingsfarbe", value_type: :string}) + |> Ash.create(actor: actor) + + %{conn: conn, custom_field: custom_field} + end + + describe "authenticated EN template" do + setup %{conn: conn} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + %{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)} + end + + test "returns CSV with English headers and current custom fields", %{conn: conn} do + conn = get(conn, ~p"/admin/import/template/en") + + assert response_content_type(conn, :csv) =~ "text/csv" + body = response(conn, 200) + + header = body |> String.split("\n") |> List.first() + assert header =~ "email" + # EN headers use the canonical English variant from HeaderMapper, not the + # underscore form, so the template stays faithful to the documented variant list. + assert header =~ "first name" + assert header =~ "last name" + refute header =~ "first_name" + assert header =~ "house number" + refute header =~ "house_number" + assert header =~ "Lieblingsfarbe" + + assert get_resp_header(conn, "content-disposition") + |> Enum.any?(&(&1 =~ "member_import_en.csv")) + end + + test "neutralizes formula-injection in a custom field header", %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, _} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "=cmd|'/c calc'!A1", + value_type: :string + }) + |> Ash.create(actor: actor) + + conn = get(conn, ~p"/admin/import/template/en") + body = response(conn, 200) + header = body |> String.split("\n") |> List.first() + + # The dangerous cell must be prefixed with a single quote so spreadsheet + # software does not evaluate it as a formula, matching the export writer. + refute header =~ ~r/(^|;)=cmd/ + assert header =~ "'=cmd|'/c calc'!A1" + end + end + + describe "authenticated DE template" do + setup %{conn: conn} do + admin = Mv.Fixtures.user_with_role_fixture("admin") + %{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)} + end + + test "returns CSV with German headers and current custom fields", %{conn: conn} do + conn = get(conn, ~p"/admin/import/template/de") + + body = response(conn, 200) + header = body |> String.split("\n") |> List.first() + + assert header =~ "E-Mail" + assert header =~ "Vorname" + assert header =~ "Lieblingsfarbe" + + assert get_resp_header(conn, "content-disposition") + |> Enum.any?(&(&1 =~ "member_import_de.csv")) + end + end + + describe "authorization" do + @tag role: :unauthenticated + test "unauthenticated request does not receive a CSV", %{conn: conn} do + conn = get(conn, ~p"/admin/import/template/en") + + refute conn.status == 200 + refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv")) + refute to_string(conn.resp_body) =~ "email" + end + + @tag role: :member + test "user without import permission is forbidden", %{conn: conn} do + conn = get(conn, ~p"/admin/import/template/en") + + refute conn.status == 200 + refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv")) + refute to_string(conn.resp_body) =~ "email" + end + end +end diff --git a/test/mv_web/live/import_live_test.exs b/test/mv_web/live/import_live_test.exs index 09ec02c..7b4dd40 100644 --- a/test/mv_web/live/import_live_test.exs +++ b/test/mv_web/live/import_live_test.exs @@ -240,10 +240,15 @@ defmodule MvWeb.ImportLiveTest do assert has_element?(view, "[data-testid='start-import-button']") end - test "template links and file input are present", %{conn: conn} do + test "template links point to the dynamic import template routes", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/import") + assert has_element?(view, "a[href='/admin/import/template/en']") + assert has_element?(view, "a[href='/admin/import/template/de']") + refute has_element?(view, "a[href*='/templates/member_import_en.csv']") + end + + test "file input is present", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") - assert has_element?(view, "a[href*='/templates/member_import_en.csv']") - assert has_element?(view, "a[href*='/templates/member_import_de.csv']") assert has_element?(view, "label[for='csv_file']") assert has_element?(view, "#csv_file_help") assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']") From 68a1a9530adc4c0afa573d65cac4b8ab65dab8d0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:25:50 +0200 Subject: [PATCH 25/47] feat(import): confirm column mapping in a preview before importing members --- lib/mv/membership/import/header_mapper.ex | 4 +- lib/mv_web/live/import_live.ex | 55 ++++- lib/mv_web/live/import_live/components.ex | 209 +++++++++++++++- priv/gettext/de/LC_MESSAGES/default.po | 109 ++++++++- priv/gettext/default.pot | 109 ++++++++- priv/gettext/en/LC_MESSAGES/default.po | 109 ++++++++- .../live/import_live/components_test.exs | 31 +++ test/mv_web/live/import_live_test.exs | 228 ++++++++++++++++-- 8 files changed, 816 insertions(+), 38 deletions(-) create mode 100644 test/mv_web/live/import_live/components_test.exs diff --git a/lib/mv/membership/import/header_mapper.ex b/lib/mv/membership/import/header_mapper.ex index eb8bb04..be90ca6 100644 --- a/lib/mv/membership/import/header_mapper.ex +++ b/lib/mv/membership/import/header_mapper.ex @@ -75,7 +75,9 @@ defmodule Mv.Membership.Import.HeaderMapper do @ignored_normalized [ "membershipfeestatus", "mitgliedsbeitragsstatus", - "bezahlstatus" + "bezahlstatus", + # DE export label for membership_fee_start_date — system-managed, not importable + "startdatummitgliedsbeitrag" ] # Normalized header variants for the groups column. The column is resolved to diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index a8c5a95..2c5aa8a 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -99,7 +99,12 @@ defmodule MvWeb.ImportLive do <.form_section title={gettext("Choose CSV file")}> - + <%= if @import_status != :preview do %> + + <% end %> + <%= if @import_status == :preview do %> + + <% end %> <%= if @import_status == :running or @import_status == :done or @import_status == :error do %> <% end %> @@ -133,6 +138,29 @@ defmodule MvWeb.ImportLive do end end + @impl true + def handle_event("confirm_import", _params, socket) do + case socket.assigns do + %{import_state: import_state} when is_map(import_state) -> + start_import(socket, import_state) + + _ -> + {:noreply, + put_flash(socket, :error, gettext("No prepared import to confirm. Please upload again."))} + end + end + + @impl true + def handle_event("cancel_import", _params, socket) do + socket = + socket + |> assign(:import_state, nil) + |> assign(:import_progress, nil) + |> assign(:import_status, :idle) + + {:noreply, socket} + end + # Checks if all prerequisites for starting an import are met. # # Validates: @@ -169,10 +197,10 @@ defmodule MvWeb.ImportLive do end end - # Processes CSV upload and starts import process. + # Processes CSV upload and enters the mapping preview. # - # Reads the uploaded CSV file, prepares it for import, and initiates - # the chunked processing workflow. + # Reads the uploaded CSV file and prepares it (read-only at the DB level), then + # shows the mapping preview. No member is created until the user confirms. @spec process_csv_upload(Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()} defp process_csv_upload(socket) do @@ -181,7 +209,7 @@ defmodule MvWeb.ImportLive do with {:ok, content} <- consume_and_read_csv(socket), {:ok, import_state} <- MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do - start_import(socket, import_state) + enter_preview(socket, import_state) else {:error, reason} when is_binary(reason) -> {:noreply, @@ -193,6 +221,19 @@ defmodule MvWeb.ImportLive do end end + # Shows the mapping preview without starting any processing. + @spec enter_preview(Phoenix.LiveView.Socket.t(), map()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + defp enter_preview(socket, import_state) do + socket = + socket + |> assign(:import_state, import_state) + |> assign(:import_progress, nil) + |> assign(:import_status, :preview) + + {:noreply, socket} + end + # Starts the import process by initializing progress tracking and scheduling the first chunk. @spec start_import(Phoenix.LiveView.Socket.t(), map()) :: {:noreply, Phoenix.LiveView.Socket.t()} @@ -263,7 +304,9 @@ defmodule MvWeb.ImportLive do custom_field_lookup: import_state.custom_field_lookup, existing_error_count: length(progress.errors), max_errors: @max_errors, - actor: actor + actor: actor, + fee_type_map: import_state.fee_type_map, + groups_found: import_state.groups_found ] _ = diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index eacc263..c317b87 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -25,7 +25,22 @@ defmodule MvWeb.ImportLive.Components do

    {gettext( - "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." + "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." + )} +

    +

    + {gettext( + "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." + )} +

    +

    + {gettext( + "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." + )} +

    +

    + {gettext( + "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." )}

    @@ -100,6 +115,194 @@ defmodule MvWeb.ImportLive.Components do """ end + @doc """ + Renders the mapping preview shown between upload and processing. + + Shows the column-to-role mapping, up to 3 sample rows, and notices for + auto-created groups, unresolved fee types, empty fee-type cells, and unknown + columns. Nothing is written until the user confirms. + """ + def preview(assigns) do + state = assigns.import_state + column_roles = column_roles(state) + column_samples = column_samples(state.preview_rows, length(state.headers)) + + assigns = + assigns + |> assign(:column_roles, column_roles) + |> assign(:column_samples, column_samples) + + ~H""" +
    +

    + {gettext("Preview import")} +

    + +
    + + + + + + + + + + + + <%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %> + + + + <%= for sample <- samples do %> + + <% end %> + + <% end %> + +
    {gettext("Role")}{gettext("Column")}{gettext("Row 1")}{gettext("Row 2")}{gettext("Row 3")}
    + + {role_label(role)} + + {header}{sample}
    +
    + + <%= if @import_state.groups_to_create != [] do %> +
    + <.icon name="hero-information-circle" class="size-5" aria-hidden="true" /> +
    +

    + {gettext("These groups will be created automatically: %{names}", + names: Enum.join(@import_state.groups_to_create, ", ") + )} +

    +
    +
    + <% end %> + + <%= if @import_state.fee_type_warnings != [] do %> + + <% end %> + + <%= if @import_state.has_empty_fee_type_cells? do %> +
    + <.icon name="hero-information-circle" class="size-5" aria-hidden="true" /> +
    +

    + {gettext("Rows with an empty fee type will get the default fee type.")} +

    +
    +
    + <% end %> + + <%= if @import_state.warnings != [] do %> + + <% end %> + +
    + <.button + type="button" + phx-click="confirm_import" + variant="primary" + data-testid="confirm-import-button" + > + {gettext("Confirm and Import")} + + <.button type="button" phx-click="cancel_import" data-testid="cancel-import-button"> + {gettext("Cancel")} + +
    +
    + """ + end + + # Pairs each CSV header with its resolved role for the preview mapping table. + defp column_roles(state) do + member_indices = MapSet.new(Map.values(state.column_map)) + custom_indices = MapSet.new(Map.values(state.custom_field_map)) + ignored_headers = MapSet.new(state.ignored) + + state.headers + |> Enum.with_index() + |> Enum.map(fn {header, index} -> + {header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)} + end) + end + + defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do + cond do + index == state.groups_column_index -> :groups + index == state.fee_type_column_index -> :fee_type + MapSet.member?(ignored_headers, header) -> :ignored + MapSet.member?(member_indices, index) -> :member_field + MapSet.member?(custom_indices, index) -> :custom_field + true -> :unknown + end + end + + defp role_label(:member_field), do: gettext("Member field") + defp role_label(:custom_field), do: gettext("Custom field") + defp role_label(:groups), do: gettext("Groups") + defp role_label(:fee_type), do: gettext("Fee type") + defp role_label(:ignored), do: gettext("Ignored (system-computed field)") + defp role_label(:unknown), do: gettext("Unknown (ignored)") + + defp role_badge_class(:member_field), do: "badge-primary" + defp role_badge_class(:custom_field), do: "badge-secondary" + defp role_badge_class(:groups), do: "badge-success" + defp role_badge_class(:fee_type), do: "badge-warning" + defp role_badge_class(:ignored), do: "badge-ghost" + defp role_badge_class(:unknown), do: "badge-error" + + defp role_row_class(:ignored), do: "opacity-50" + defp role_row_class(:unknown), do: "opacity-50" + defp role_row_class(_), do: nil + + defp column_samples([], col_count), do: List.duplicate([], col_count) + + defp column_samples(rows, col_count) do + Enum.map(0..(col_count - 1), fn col_idx -> + rows + |> Enum.map(fn row -> Enum.at(row, col_idx, "") end) + |> pad_to(3, "") + end) + end + + defp pad_to(list, target, fill) do + list ++ List.duplicate(fill, max(0, target - length(list))) + end + @doc """ Renders import progress text and, when done or aborted, the import results section. """ @@ -246,8 +449,10 @@ defmodule MvWeb.ImportLive.Components do @doc """ Returns whether the Start Import button should be disabled. """ - @spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean() + @spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) :: + boolean() def import_button_disabled?(:running, _entries), do: true + def import_button_disabled?(:preview, _entries), do: true def import_button_disabled?(_status, []), do: true def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true def import_button_disabled?(_status, _entries), do: false diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 9fa6cd4..343ede8 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -390,6 +390,7 @@ msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur z #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -1329,6 +1330,7 @@ msgstr "Feb." msgid "Fee Type" msgstr "Beitragsart" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1488,6 +1490,7 @@ msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -2643,6 +2646,7 @@ msgstr "Geprüft von" msgid "Reviewed at" msgstr "Geprüft am" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex @@ -3303,11 +3307,6 @@ msgstr "Aufhebung der Verknüpfung geplant" msgid "Unpaid" msgstr "Unbezahlt" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." -msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden." - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3977,3 +3976,103 @@ msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwende #, elixir-autogen, elixir-format msgid "Group assignment failed: %{reason}" msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "Bestätigen und importieren" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "Kein vorbereiteter Import zum Bestätigen. Bitte erneut hochladen." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "Importvorschau" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "Spalte" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "Benutzerdefiniertes Feld" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "Ignoriert (vom System berechnetes Feld)" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Member field" +msgstr "Mitgliedsfeld" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "Zeilen ohne Beitragsart erhalten die Standard-Beitragsart." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "Diese Gruppen werden automatisch erstellt: %{names}" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown (ignored)" +msgstr "Unbekannt (ignoriert)" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "Unbekannte Beitragsarten (Mitglieder erhalten den Standard): %{names}" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "Gruppen-Spalte (erkannte Spaltennamen): Groups, Gruppen, Gruppe. Mehrere durch Komma getrennte Gruppennamen werden unterstützt; fehlende Gruppen werden automatisch erstellt." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "Datenfeld erstellen" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "Beitragsart erstellen" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "Zeile 1" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "Zeile 2" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "Zeile 3" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index c961420..f14f7a1 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -391,6 +391,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -1330,6 +1331,7 @@ msgstr "" msgid "Fee Type" msgstr "" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1489,6 +1491,7 @@ msgstr "" #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -2644,6 +2647,7 @@ msgstr "" msgid "Reviewed at" msgstr "" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex @@ -3304,11 +3308,6 @@ msgstr "" msgid "Unpaid" msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3977,3 +3976,103 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Group assignment failed: %{reason}" msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Member field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown (ignored)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 58aeead..18d1e30 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -391,6 +391,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -1330,6 +1331,7 @@ msgstr "" msgid "Fee Type" msgstr "Fee Type" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Fee type" @@ -1489,6 +1491,7 @@ msgstr "" #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -2644,6 +2647,7 @@ msgstr "Review by" msgid "Reviewed at" msgstr "Review date" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex @@ -3304,11 +3308,6 @@ msgstr "" msgid "Unpaid" msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3977,3 +3976,103 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Group assignment failed: %{reason}" msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Member field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Unknown (ignored)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "" diff --git a/test/mv_web/live/import_live/components_test.exs b/test/mv_web/live/import_live/components_test.exs new file mode 100644 index 0000000..3870ed6 --- /dev/null +++ b/test/mv_web/live/import_live/components_test.exs @@ -0,0 +1,31 @@ +defmodule MvWeb.ImportLive.ComponentsTest do + use ExUnit.Case, async: true + + alias MvWeb.ImportLive.Components + + describe "import_button_disabled?/2" do + @done_entry %{done?: true} + + test "disables the Start Import button while the preview is displayed" do + # During :preview the upload entry is done, but re-clicking Start Import + # would re-run the upload processing and overwrite the current preview. + assert Components.import_button_disabled?(:preview, [@done_entry]) == true + end + + test "disables the button while an import is running" do + assert Components.import_button_disabled?(:running, [@done_entry]) == true + end + + test "disables the button when there are no upload entries" do + assert Components.import_button_disabled?(:idle, []) == true + end + + test "disables the button while an upload entry is not yet done" do + assert Components.import_button_disabled?(:idle, [%{done?: false}]) == true + end + + test "enables the button at idle with a completed upload" do + assert Components.import_button_disabled?(:idle, [@done_entry]) == false + end + end +end diff --git a/test/mv_web/live/import_live_test.exs b/test/mv_web/live/import_live_test.exs index 7b4dd40..bd5cdec 100644 --- a/test/mv_web/live/import_live_test.exs +++ b/test/mv_web/live/import_live_test.exs @@ -27,6 +27,16 @@ defmodule MvWeb.ImportLiveTest do defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit() + defp confirm_import(view), + do: view |> element("[data-testid='confirm-import-button']") |> render_click() + + # Full flow: upload, enter preview (start), then confirm to begin processing. + defp run_full_import(view, csv_content, filename \\ "test_import.csv") do + upload_csv_file(view, csv_content, filename) + submit_import(view) + confirm_import(view) + end + defp wait_for_import_completion, do: Process.sleep(1000) # ---------- Business logic: Authorization ---------- @@ -56,8 +66,7 @@ defmodule MvWeb.ImportLiveTest do |> File.read!() {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content) - submit_import(view) + run_full_import(view, csv_content) wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -121,8 +130,7 @@ defmodule MvWeb.ImportLiveTest do invalid_csv: csv_content } do {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content, "invalid_import.csv") - submit_import(view) + run_full_import(view, csv_content, "invalid_import.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -141,8 +149,7 @@ defmodule MvWeb.ImportLiveTest do invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Country#{i};City#{i};Street#{i};12345\n" - upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv") - submit_import(view) + run_full_import(view, header <> Enum.join(invalid_rows), "large_invalid.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -174,8 +181,7 @@ defmodule MvWeb.ImportLiveTest do Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"]) |> File.read!() - upload_csv_file(view, csv_content, "bom_import.csv") - submit_import(view) + run_full_import(view, csv_content, "bom_import.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -193,8 +199,7 @@ defmodule MvWeb.ImportLiveTest do Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"]) |> File.read!() - upload_csv_file(view, csv_content, "empty_lines.csv") - submit_import(view) + run_full_import(view, csv_content, "empty_lines.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-error-list']") @@ -208,8 +213,7 @@ defmodule MvWeb.ImportLiveTest do unknown_custom_field_csv: csv_content } do {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content, "unknown_custom.csv") - submit_import(view) + run_full_import(view, csv_content, "unknown_custom.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -254,14 +258,27 @@ defmodule MvWeb.ImportLiveTest do assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']") end + test "custom fields notice lists accepted groups and fee-type column names", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import") + + # Groups column variants (both EN and DE) + assert html =~ "Groups" + assert html =~ "Gruppen" + # Fee type column variants (both EN and DE) + assert html =~ "Beitragsart" + assert html =~ "Fee Type" + assert html =~ "fee type" + # Fee status is always ignored (named explicitly) + assert html =~ "Bezahlstatus" + end + test "after successful import, progress container has aria-live", %{conn: conn} do csv_content = Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) |> File.read!() {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content) - submit_import(view) + run_full_import(view, csv_content) wait_for_import_completion() assert has_element?(view, "[data-testid='import-progress-container']") html = render(view) @@ -280,4 +297,187 @@ defmodule MvWeb.ImportLiveTest do html = render(view) assert html =~ "Failed to prepare" end + + describe "preview state machine" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(admin_user) + |> put_locale_en() + + valid_csv = + Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) + |> File.read!() + + {:ok, conn: conn, valid_csv: valid_csv} + end + + test "start_import transitions to preview without processing", %{ + conn: conn, + valid_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv_content) + submit_import(view) + + # Preview is shown; no results panel yet because nothing was processed. + assert has_element?(view, "[data-testid='import-preview']") + refute has_element?(view, "[data-testid='import-results-panel']") + + # No member was created during preview (read-only step). + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, members} = Membership.list_members(actor: system_actor) + + refute Enum.any?( + members, + &(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"]) + ) + end + + test "confirm_import starts processing and creates members", %{ + conn: conn, + valid_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import") + run_full_import(view, csv_content) + wait_for_import_completion() + + assert has_element?(view, "[data-testid='import-results-panel']") + + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, members} = Membership.list_members(actor: system_actor) + + imported = + Enum.filter( + members, + &(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"]) + ) + + assert length(imported) == 2 + end + + test "cancel_import returns to idle and hides the preview", %{ + conn: conn, + valid_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv_content) + submit_import(view) + assert has_element?(view, "[data-testid='import-preview']") + + view |> element("[data-testid='cancel-import-button']") |> render_click() + + refute has_element?(view, "[data-testid='import-preview']") + refute has_element?(view, "[data-testid='import-results-panel']") + end + end + + describe "preview contents" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(admin_user) + |> put_locale_en() + + {:ok, conn: conn} + end + + test "shows the column mapping table with roles for each column", %{conn: conn} do + csv = "email;Gruppen;Beitragsart;Bezahlstatus;UnknownCol\na@e.com;Chor;Premium;paid;x" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-mapping-table']") + html = render(view) + + assert html =~ "email" + assert html =~ "Gruppen" + assert html =~ "Beitragsart" + assert html =~ "Bezahlstatus" + assert html =~ "UnknownCol" + end + + test "lists every CSV column exactly once in the mapping table", %{conn: conn} do + headers = ["email", "Gruppen", "Beitragsart", "Bezahlstatus", "UnknownCol"] + csv = Enum.join(headers, ";") <> "\na@e.com;Chor;Premium;paid;x" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + # Count the data rows via their stable testid so the assertion is independent + # of how Phoenix renders class attributes or tr tags (§1.15). + html = render(view) + + row_count = + html |> String.split(~s(data-testid="preview-column-row")) |> length() |> Kernel.-(1) + + assert row_count == length(headers) + end + + test "shows up to 3 sample data rows", %{conn: conn} do + csv = "email\nr1@e.com\nr2@e.com\nr3@e.com\nr4@e.com" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + html = render(view) + assert html =~ "r1@e.com" + assert html =~ "r2@e.com" + assert html =~ "r3@e.com" + refute html =~ "r4@e.com" + end + + test "shows an auto-create notice for unknown group names", %{conn: conn} do + csv = "email;Gruppen\na@e.com;Ganz Neue Gruppe" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-groups-notice']") + assert render(view) =~ "Ganz Neue Gruppe" + end + + test "shows a warning and link for unknown fee-type names", %{conn: conn} do + csv = "email;Beitragsart\na@e.com;Phantom Tarif" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-fee-type-warning']") + html = render(view) + assert html =~ "Phantom Tarif" + assert html =~ "/membership_fee_settings" + end + + test "shows an info notice when fee-type cells are empty", %{conn: conn} do + csv = "email;Beitragsart\na@e.com;\nb@e.com;" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-fee-type-info']") + end + + test "shows a warning for unknown custom-field columns", %{conn: conn} do + csv = "email;TotallyUnknown\na@e.com;value" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-unknown-warning']") + assert render(view) =~ "TotallyUnknown" + end + end end From 118b9f8d57bdd09a259e6f1c8513f662d2d4d325 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:32:15 +0200 Subject: [PATCH 26/47] perf(import): reuse auto-created groups across import chunks --- lib/mv/membership/import/import_runner.ex | 14 ++++++ lib/mv/membership/import/member_csv.ex | 11 +++-- lib/mv_web/live/import_live.ex | 3 ++ .../membership/import/import_runner_test.exs | 18 +++++++ test/mv/membership/import/member_csv_test.exs | 47 +++++++++++++++++++ 5 files changed, 90 insertions(+), 3 deletions(-) diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex index 5f953d4..b56cae6 100644 --- a/lib/mv/membership/import/import_runner.ex +++ b/lib/mv/membership/import/import_runner.ex @@ -97,6 +97,20 @@ defmodule Mv.Membership.Import.ImportRunner do } end + @doc """ + Carries the in-memory group snapshot grown by a chunk back into `import_state` + so the next chunk reuses groups created earlier instead of re-reading the + Group table. When the chunk result omits `groups_found`, the state is returned + unchanged. + """ + @spec carry_groups_forward(map(), map()) :: map() + def carry_groups_forward(import_state, chunk_result) do + case Map.fetch(chunk_result, :groups_found) do + {:ok, groups_found} -> Map.put(import_state, :groups_found, groups_found) + :error -> import_state + end + end + @doc """ Returns the next action after processing a chunk: send the next chunk index or done. """ diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index d0ab74b..31dea59 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -37,6 +37,9 @@ defmodule Mv.Membership.Import.MemberCSV do - `inserted` - Number of successfully created members - `failed` - Number of failed member creations - `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import) + - `groups_found` - The in-memory group snapshot grown while processing this + chunk; thread it into the next chunk's `:groups_found` opt so groups created + in an earlier chunk are reused without re-reading the Group table ## Examples @@ -94,7 +97,8 @@ defmodule Mv.Membership.Import.MemberCSV do failed: non_neg_integer(), errors: list(Error.t()), errors_truncated?: boolean(), - warnings: list(String.t()) + warnings: list(String.t()), + groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()}) } alias Mv.Membership.Import.ColumnResolver @@ -374,7 +378,7 @@ defmodule Mv.Membership.Import.MemberCSV do actor: actor } - {inserted, failed, errors, _collected_error_count, truncated?, warnings, _groups_acc} = + {inserted, failed, errors, _collected_error_count, truncated?, warnings, groups_acc} = Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false, [], groups_found}, fn {line_number, row_map}, {acc_inserted, @@ -417,7 +421,8 @@ defmodule Mv.Membership.Import.MemberCSV do failed: failed, errors: Enum.reverse(errors), errors_truncated?: truncated?, - warnings: warnings + warnings: warnings, + groups_found: groups_acc }} end diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 2c5aa8a..cd7f6d3 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -367,8 +367,11 @@ defmodule MvWeb.ImportLive do new_progress = ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors) + new_import_state = ImportRunner.carry_groups_forward(import_state, chunk_result) + socket = socket + |> assign(:import_state, new_import_state) |> assign(:import_progress, new_progress) |> assign(:import_status, new_progress.status) |> maybe_send_next_chunk(idx, length(import_state.chunks)) diff --git a/test/mv/membership/import/import_runner_test.exs b/test/mv/membership/import/import_runner_test.exs index 88d189e..22d21bd 100644 --- a/test/mv/membership/import/import_runner_test.exs +++ b/test/mv/membership/import/import_runner_test.exs @@ -3,6 +3,24 @@ defmodule Mv.Membership.Import.ImportRunnerTest do alias Mv.Membership.Import.ImportRunner + describe "carry_groups_forward/2" do + test "replaces import_state groups_found with the chunk's grown snapshot" do + import_state = %{groups_found: [%{id: "1", name: "A"}]} + chunk_result = %{groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}]} + + assert ImportRunner.carry_groups_forward(import_state, chunk_result) == %{ + groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}] + } + end + + test "leaves import_state unchanged when the chunk result omits groups_found" do + import_state = %{groups_found: [%{id: "1", name: "A"}], other: :kept} + chunk_result = %{inserted: 1} + + assert ImportRunner.carry_groups_forward(import_state, chunk_result) == import_state + end + end + describe "read_file_entry/2" do test "returns {:ok, content} for a readable file" do path = diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs index 0701a92..91adb91 100644 --- a/test/mv/membership/import/member_csv_test.exs +++ b/test/mv/membership/import/member_csv_test.exs @@ -1116,6 +1116,53 @@ defmodule Mv.Membership.Import.MemberCSVTest do "Expected the group table read at most a few times, got #{reads} reads for 10 rows (N+1)." end + test "returns the grown group snapshot so later chunks skip the table read", + %{actor: actor} do + chunk1 = [ + {2, %{member: %{email: "g-xchunk-1@example.com"}, custom: %{}, groups: "Shared X"}} + ] + + chunk2 = [ + {3, %{member: %{email: "g-xchunk-2@example.com"}, custom: %{}, groups: "Shared X"}} + ] + + assert {:ok, result1} = + MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, actor: actor, groups_found: []) + + # The chunk result must expose the accumulated snapshot, including the group + # auto-created while processing this chunk, so the LiveView can thread it + # into the next chunk's opts. + assert is_list(result1.groups_found) + assert Enum.any?(result1.groups_found, &(&1.name == "Shared X")) + + group_read_count = Agent.start_link(fn -> 0 end) |> elem(1) + test_pid = self() + + handler = fn _event, _measurements, metadata, _config -> + if self() == test_pid and metadata[:source] == "groups" and + is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do + Agent.update(group_read_count, &(&1 + 1)) + end + end + + handler_id = "test-xchunk-group-read-#{System.unique_integer([:positive])}" + :telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil) + + assert {:ok, %{inserted: 1}} = + MemberCSV.process_chunk(chunk2, %{email: 0}, %{}, + actor: actor, + groups_found: result1.groups_found + ) + + reads = Agent.get(group_read_count, & &1) + :telemetry.detach(handler_id) + + # The second chunk receives the snapshot grown by the first, so the shared + # group resolves from memory without any full-table read. + assert reads == 0, + "Expected no group table read in the second chunk, got #{reads} (snapshot not threaded across chunks)." + end + test "empty groups cell leaves the member without group assignment", %{actor: actor} do chunk = [{2, %{member: %{email: "g-empty@example.com"}, custom: %{}, groups: " "}}] opts = [actor: actor, groups_found: []] From 45c9b819838d46461cfd7431c805f86d667ae7bd Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:37:12 +0200 Subject: [PATCH 27/47] fix(import): collapse duplicate fee-type warnings into a bounded list --- lib/mv/membership/import/import_runner.ex | 2 +- .../membership/import/import_runner_test.exs | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex index b56cae6..28893a3 100644 --- a/lib/mv/membership/import/import_runner.ex +++ b/lib/mv/membership/import/import_runner.ex @@ -80,7 +80,7 @@ defmodule Mv.Membership.Import.ImportRunner do all_errors = progress.errors ++ chunk_result.errors new_errors = Enum.take(all_errors, max_errors) errors_truncated? = length(all_errors) > max_errors - new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, []) + new_warnings = Enum.uniq(progress.warnings ++ Map.get(chunk_result, :warnings, [])) chunks_processed = current_chunk_idx + 1 new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running diff --git a/test/mv/membership/import/import_runner_test.exs b/test/mv/membership/import/import_runner_test.exs index 22d21bd..b5fe3c2 100644 --- a/test/mv/membership/import/import_runner_test.exs +++ b/test/mv/membership/import/import_runner_test.exs @@ -21,6 +21,65 @@ defmodule Mv.Membership.Import.ImportRunnerTest do end end + describe "merge_progress/4 warning accumulation" do + test "deduplicates identical warnings across chunks instead of growing unbounded" do + progress = %{ + inserted: 0, + failed: 0, + errors: [], + warnings: ["Fee type 'Ghost' not found; using the default fee type."], + status: :running, + current_chunk: 0, + total_chunks: 3 + } + + chunk_result = %{ + inserted: 2, + failed: 0, + errors: [], + errors_truncated?: false, + warnings: [ + "Fee type 'Ghost' not found; using the default fee type.", + "Fee type 'Ghost' not found; using the default fee type." + ] + } + + result = ImportRunner.merge_progress(progress, chunk_result, 0) + + assert result.warnings == ["Fee type 'Ghost' not found; using the default fee type."] + end + + test "preserves distinct warnings while collapsing duplicates" do + progress = %{ + inserted: 0, + failed: 0, + errors: [], + warnings: ["Fee type 'A' not found; using the default fee type."], + status: :running, + current_chunk: 0, + total_chunks: 2 + } + + chunk_result = %{ + inserted: 1, + failed: 0, + errors: [], + errors_truncated?: false, + warnings: [ + "Fee type 'A' not found; using the default fee type.", + "Fee type 'B' not found; using the default fee type." + ] + } + + result = ImportRunner.merge_progress(progress, chunk_result, 0) + + assert result.warnings == [ + "Fee type 'A' not found; using the default fee type.", + "Fee type 'B' not found; using the default fee type." + ] + end + end + describe "read_file_entry/2" do test "returns {:ok, content} for a readable file" do path = From 2bc5fcec5ac655dac6abd06b3ebcddf9dda234bc Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:37:46 +0200 Subject: [PATCH 28/47] docs(changelog): record CSV import improvements under Unreleased --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8032c..edb53f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups. +- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it. +- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm. +- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set. + +### Fixed +- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors. +- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists. + ## [1.2.0] - 2026-05-08 ### Changed From b6c2cf58b1c86f782e1b84391d22602aae677dc1 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 3 Jun 2026 12:01:41 +0200 Subject: [PATCH 29/47] feat(custom-field): add join_description attribute for GDPR join-form labels --- lib/membership/custom_field.ex | 24 ++- ..._add_join_description_to_custom_fields.exs | 21 +++ .../repo/custom_fields/20260603000204.json | 145 ++++++++++++++++++ .../custom_field_validation_test.exs | 55 +++++++ 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20260603000204.json diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ef6c79a..5f4dd0e 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description + - `join_description` - Optional label shown for this field on the public join form + (e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil. - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -61,7 +63,14 @@ defmodule Mv.Membership.CustomField do end actions do - default_accept [:name, :value_type, :description, :required, :show_in_overview] + default_accept [ + :name, + :value_type, + :description, + :join_description, + :required, + :show_in_overview + ] read :read do primary? true @@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do end create :create do - accept [:name, :value_type, :description, :required, :show_in_overview] + accept [:name, :value_type, :description, :join_description, :required, :show_in_overview] change Mv.Membership.Changes.GenerateSlug validate string_length(:slug, min: 1) end update :update do - accept [:name, :description, :required, :show_in_overview] + accept [:name, :description, :join_description, :required, :show_in_overview] require_atomic? false validate fn changeset, _context -> @@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do trim?: true ] + attribute :join_description, :string, + allow_nil?: true, + public?: true, + description: "Label shown for this field on the public join form; supports external links", + constraints: [ + max_length: 1000, + trim?: true + ] + attribute :required, :boolean, default: false, allow_nil?: false diff --git a/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs new file mode 100644 index 0000000..b1d9a05 --- /dev/null +++ b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddJoinDescriptionToCustomFields do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:custom_fields) do + add :join_description, :text + end + end + + def down do + alter table(:custom_fields) do + remove :join_description + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20260603000204.json b/priv/resource_snapshots/repo/custom_fields/20260603000204.json new file mode 100644 index 0000000..aa0b0ed --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20260603000204.json @@ -0,0 +1,145 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "show_in_overview", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "2600667D140A2A846F9A848ACEFCADA1F1206950B38EF407B0BB13816E508A2A", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index e642d82..9b1faf5 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -159,6 +159,61 @@ defmodule Mv.Membership.CustomFieldValidationTest do end end + describe "join_description" do + test "persists join_description when set", %{actor: actor} do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "dsgvo_field", + value_type: :boolean, + join_description: "hereby I confirm the GDPR" + }) + |> Ash.create(actor: actor) + + assert custom_field.join_description == "hereby I confirm the GDPR" + end + + test "defaults to nil when not given", %{actor: actor} do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "no_join_desc", + value_type: :boolean + }) + |> Ash.create(actor: actor) + + assert custom_field.join_description == nil + end + + test "rejects join_description longer than 1000 characters", %{actor: actor} do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "too_long_join_desc", + value_type: :boolean, + join_description: String.duplicate("a", 1001) + }) + |> Ash.create(actor: actor) + + assert [%{field: :join_description, message: message}] = changeset.errors + assert message =~ "max" or message =~ "length" or message =~ "1000" + end + + test "is writable via the update action", %{actor: actor} do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{name: "updatable", value_type: :boolean}) + |> Ash.create(actor: actor) + + assert {:ok, updated} = + custom_field + |> Ash.Changeset.for_update(:update, %{join_description: "Accept the GDPR"}) + |> Ash.update(actor: actor) + + assert updated.join_description == "Accept the GDPR" + end + end + describe "name uniqueness" do test "rejects duplicate names", %{actor: actor} do assert {:ok, _} = From cb5cb6848323d8d7bb00ac939fabe27a5d5a12e8 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 3 Jun 2026 12:06:48 +0200 Subject: [PATCH 30/47] feat(join): render join_description with auto-linked URLs and Markdown links --- .../helpers/join_description_renderer.ex | 70 +++++++++++++++ .../join_description_renderer_test.exs | 85 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 lib/mv_web/helpers/join_description_renderer.ex create mode 100644 test/mv_web/helpers/join_description_renderer_test.exs diff --git a/lib/mv_web/helpers/join_description_renderer.ex b/lib/mv_web/helpers/join_description_renderer.ex new file mode 100644 index 0000000..121b02b --- /dev/null +++ b/lib/mv_web/helpers/join_description_renderer.ex @@ -0,0 +1,70 @@ +defmodule MvWeb.Helpers.JoinDescriptionRenderer do + @moduledoc """ + Renders a custom field's `join_description` into Phoenix-safe HTML for the + public join form. + + The renderer auto-links two patterns into `` tags: + + - Markdown links of the form `[text](url)` (processed first) + - bare `http(s)://` URLs in the remaining text + + All other content is HTML-escaped: only `` tags are ever + emitted, so arbitrary HTML in the input is rendered as inert text. This is a + defense-in-depth measure — `join_description` is admin-set content, never + end-user input — but the renderer must not become a vector for injecting + arbitrary markup. + + Markdown links are matched before bare URLs and their matched region is + consumed, so a Markdown link whose URL also looks like a bare URL is linked + exactly once (no nested anchors). + """ + + @markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/ + @bare_url ~r/(https?:\/\/[^\s<]+)/ + @bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/ + + @doc """ + Converts `value` to a Phoenix-safe HTML iolist. + + Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with + links rendered and all other text HTML-escaped. + """ + @spec render(String.t() | nil) :: Phoenix.HTML.safe() + def render(nil), do: {:safe, ""} + + def render(value) when is_binary(value) do + {:safe, render_segments(value)} + end + + # Split on Markdown links first; for each non-Markdown segment, link bare URLs; + # everything that is not a link is HTML-escaped. + defp render_segments(text) do + Regex.split(@markdown_link, text, include_captures: true) + |> Enum.map(&render_markdown_or_plain/1) + end + + defp render_markdown_or_plain(segment) do + case Regex.run(@markdown_link, segment) do + [^segment, label, url] -> anchor(url, label) + _ -> render_plain(segment) + end + end + + # Auto-link bare URLs in a plain-text segment, escaping all surrounding text. + defp render_plain(segment) do + Regex.split(@bare_url, segment, include_captures: true) + |> Enum.map(fn part -> + if Regex.match?(@bare_url_anchored, part) do + anchor(part, part) + else + escape(part) + end + end) + end + + defp anchor(url, label) do + ["", escape(label), ""] + end + + defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string() +end diff --git a/test/mv_web/helpers/join_description_renderer_test.exs b/test/mv_web/helpers/join_description_renderer_test.exs new file mode 100644 index 0000000..62eb91d --- /dev/null +++ b/test/mv_web/helpers/join_description_renderer_test.exs @@ -0,0 +1,85 @@ +defmodule MvWeb.Helpers.JoinDescriptionRendererTest do + @moduledoc """ + Tests for the join-description renderer that auto-links raw URLs and Markdown + links while escaping all other content. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias MvWeb.Helpers.JoinDescriptionRenderer + + defp html(value) do + value + |> JoinDescriptionRenderer.render() + |> Phoenix.HTML.safe_to_string() + end + + describe "render/1" do + test "converts a raw URL to an anchor tag" do + result = html("Akzeptiere https://example.com/dsgvo") + + assert result =~ ~s(" + assert result =~ "Akzeptiere " + end + + test "converts Markdown [text](url) to an anchor tag with the link text" do + result = html("[Datenschutzerklärung](https://example.com/dsgvo)") + + assert result =~ ~s(Datenschutzerklärung" + end + + test "returns an empty safe string for nil input" do + assert JoinDescriptionRenderer.render(nil) == {:safe, ""} + end + + test "escapes arbitrary HTML in non-link text" do + result = html("") + + refute result =~ "