From 056fd04ddf8cf98e85d470c643f5f3571df808a8 Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 23 Feb 2026 16:24:20 +0100 Subject: [PATCH 01/12] feat: remove postal code validation --- docs/database-schema-readme.md | 2 +- docs/database_schema.dbml | 2 +- lib/membership/member.ex | 6 ------ test/membership/member_test.exs | 12 +++--------- 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 6e444a5..de1c158 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -191,7 +191,7 @@ Settings (1) → MembershipFeeType (0..1) - Join date cannot be in future - Exit date must be after join date - Phone: `+?[0-9\- ]{6,20}` -- Postal code: 5 digits +- Postal code: optional (no format validation) ### CustomFieldValue System - Maximum one custom field value per custom field per member diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 23605bf..e09af58 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -188,7 +188,7 @@ Table members { - email: 5-254 characters, valid email format (required) - join_date: cannot be in future - exit_date: must be after join_date (if both present) - - postal_code: exactly 5 digits (if present) + - postal_code: optional (no format validation) ''' } diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 476501c..797549a 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -22,7 +22,6 @@ defmodule Mv.Membership.Member do ## Validations - Required: email (all other fields are optional) - Email format validation (using EctoCommons.EmailValidator) - - Postal code format: exactly 5 digits (German format) - Date validations: join_date not in future, exit_date after join_date - Email uniqueness: prevents conflicts with unlinked users - Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`) @@ -458,11 +457,6 @@ defmodule Mv.Membership.Member do where: [present([:join_date, :exit_date])], message: "cannot be before join date" - # Postal code format (only if set) - validate match(:postal_code, ~r/^\d{5}$/), - where: [present(:postal_code)], - message: "must consist of 5 digits" - # Email validation with EctoCommons.EmailValidator validate fn changeset, _ -> email = Ash.Changeset.get_attribute(changeset, :email) diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index 705ab61..0121b29 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -80,15 +80,9 @@ defmodule Mv.Membership.MemberTest do assert {:ok, _member} = Membership.create_member(attrs, actor: actor) end - test "Postal code is optional but must have 5 digits if specified", %{actor: actor} do - attrs = Map.put(@valid_attrs, :postal_code, "1234") - - assert {:error, %Ash.Error.Invalid{errors: errors}} = - Membership.create_member(attrs, actor: actor) - - assert error_message(errors, :postal_code) =~ "must consist of 5 digits" - attrs2 = Map.delete(@valid_attrs, :postal_code) - assert {:ok, _member} = Membership.create_member(attrs2, actor: actor) + test "Postal code is optional", %{actor: actor} do + attrs = Map.delete(@valid_attrs, :postal_code) + assert {:ok, _member} = Membership.create_member(attrs, actor: actor) end end -- 2.47.2 From 1fd188042432c41f304226de93f3e4d140a278ee Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 08:31:52 +0100 Subject: [PATCH 02/12] chore: adds country memberfield --- lib/membership/member.ex | 7 +- lib/mv/constants.ex | 1 + lib/mv_web/translations/member_fields.ex | 1 + .../20260223120000_add_country_to_members.exs | 577 ++++++++++++++++++ 4 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20260223120000_add_country_to_members.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 797549a..7977fae 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -574,6 +574,10 @@ defmodule Mv.Membership.Member do allow_nil? true end + attribute :country, :string do + allow_nil? true + end + attribute :search_vector, AshPostgres.Tsvector, writable?: false, public?: false, @@ -1167,7 +1171,8 @@ defmodule Mv.Membership.Member do contains(postal_code, ^query) or contains(house_number, ^query) or contains(email, ^query) or - contains(city, ^query) + contains(city, ^query) or + contains(country, ^query) ) end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 4ef355d..1de7916 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -10,6 +10,7 @@ defmodule Mv.Constants do :join_date, :exit_date, :notes, + :country, :city, :street, :house_number, diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index 83ab139..cf40c2a 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -27,6 +27,7 @@ defmodule MvWeb.Translations.MemberFields do def label(:street), do: gettext("Street") def label(:house_number), do: gettext("House Number") def label(:postal_code), do: gettext("Postal Code") + def label(:country), do: gettext("Country") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_status), do: gettext("Membership Fee Status") diff --git a/priv/repo/migrations/20260223120000_add_country_to_members.exs b/priv/repo/migrations/20260223120000_add_country_to_members.exs new file mode 100644 index 0000000..e2513f4 --- /dev/null +++ b/priv/repo/migrations/20260223120000_add_country_to_members.exs @@ -0,0 +1,577 @@ +defmodule Mv.Repo.Migrations.AddCountryToMembers do + @moduledoc """ + Adds country as an optional member field and includes it in full-text search. + + - Adds :country column to members table (text, nullable) + - Updates members_search_vector_trigger() to include country (weight C) + - Updates update_member_search_vector_from_custom_field_value() to include country + - Updates update_member_search_vector_from_member_groups() to include country + - Backfills existing members' search_vector with country + """ + + use Ecto.Migration + + def up do + alter table(:members) do + add :country, :text + end + + # 1. Main trigger on members: add country to search_vector + execute(""" + CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$ + DECLARE + custom_values_text text; + groups_text text; + BEGIN + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = NEW.id AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = NEW.id; + + NEW.search_vector := + setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.country, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + """) + + # 2. Custom field trigger: include country in recomputed search_vector + execute(""" + CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$ + DECLARE + member_id_val uuid; + member_first_name text; + member_last_name text; + member_email text; + member_join_date date; + member_exit_date date; + member_notes text; + member_city text; + member_street text; + member_house_number text; + member_postal_code text; + member_country text; + custom_values_text text; + groups_text text; + old_value_text text; + new_value_text text; + BEGIN + member_id_val := COALESCE(NEW.member_id, OLD.member_id); + + IF TG_OP = 'UPDATE' THEN + old_value_text := COALESCE( + NULLIF(OLD.value->>'_union_value', ''), + NULLIF(OLD.value->>'value', ''), + '' + ); + new_value_text := COALESCE( + NULLIF(NEW.value->>'_union_value', ''), + NULLIF(NEW.value->>'value', ''), + '' + ); + IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND + (OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND + (OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN + RETURN COALESCE(NEW, OLD); + END IF; + END IF; + + SELECT + first_name, + last_name, + email, + join_date, + exit_date, + notes, + city, + street, + house_number, + postal_code, + country + INTO + member_first_name, + member_last_name, + member_email, + member_join_date, + member_exit_date, + member_notes, + member_city, + member_street, + member_house_number, + member_postal_code, + member_country + FROM members + WHERE id = member_id_val; + + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = member_id_val AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = member_id_val; + + UPDATE members + SET search_vector = + setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_country, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B') + WHERE id = member_id_val; + + RETURN COALESCE(NEW, OLD); + END + $$ LANGUAGE plpgsql; + """) + + # 3. Member groups trigger: include country when refreshing search_vector + execute(""" + CREATE OR REPLACE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$ + DECLARE + member_id_val uuid; + member_first_name text; + member_last_name text; + member_email text; + member_join_date date; + member_exit_date date; + member_notes text; + member_city text; + member_street text; + member_house_number text; + member_postal_code text; + member_country text; + custom_values_text text; + groups_text text; + BEGIN + FOR member_id_val IN + SELECT COALESCE(NEW.member_id, OLD.member_id) + UNION ALL + SELECT OLD.member_id + WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id + LOOP + SELECT + first_name, + last_name, + email, + join_date, + exit_date, + notes, + city, + street, + house_number, + postal_code, + country + INTO + member_first_name, + member_last_name, + member_email, + member_join_date, + member_exit_date, + member_notes, + member_city, + member_street, + member_house_number, + member_postal_code, + member_country + FROM members + WHERE id = member_id_val; + + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = member_id_val AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = member_id_val; + + UPDATE members + SET search_vector = + setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_country, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B') + WHERE id = member_id_val; + END LOOP; + + RETURN COALESCE(NEW, OLD); + END + $$ LANGUAGE plpgsql; + """) + + # 4. Backfill: update all members' search_vector to include country + execute(""" + UPDATE members m + SET search_vector = + setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.country, '')), 'C') || + setweight(to_tsvector('simple', coalesce( + (SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + FROM custom_field_values + WHERE member_id = m.id AND value IS NOT NULL), + '' + )), 'C') || + setweight(to_tsvector('simple', coalesce( + (SELECT string_agg(g.name, ' ') + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = m.id), + '' + )), 'B') + """) + end + + def down do + # Restore trigger functions without country (revert to previous version from AddGroupNamesToMemberSearchVector) + execute(""" + CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$ + DECLARE + custom_values_text text; + groups_text text; + BEGIN + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = NEW.id AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = NEW.id; + + NEW.search_vector := + setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + """) + + execute(""" + CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$ + DECLARE + member_id_val uuid; + member_first_name text; + member_last_name text; + member_email text; + member_join_date date; + member_exit_date date; + member_notes text; + member_city text; + member_street text; + member_house_number text; + member_postal_code text; + custom_values_text text; + groups_text text; + old_value_text text; + new_value_text text; + BEGIN + member_id_val := COALESCE(NEW.member_id, OLD.member_id); + + IF TG_OP = 'UPDATE' THEN + old_value_text := COALESCE( + NULLIF(OLD.value->>'_union_value', ''), + NULLIF(OLD.value->>'value', ''), + '' + ); + new_value_text := COALESCE( + NULLIF(NEW.value->>'_union_value', ''), + NULLIF(NEW.value->>'value', ''), + '' + ); + IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND + (OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND + (OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN + RETURN COALESCE(NEW, OLD); + END IF; + END IF; + + SELECT + first_name, + last_name, + email, + join_date, + exit_date, + notes, + city, + street, + house_number, + postal_code + INTO + member_first_name, + member_last_name, + member_email, + member_join_date, + member_exit_date, + member_notes, + member_city, + member_street, + member_house_number, + member_postal_code + FROM members + WHERE id = member_id_val; + + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = member_id_val AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = member_id_val; + + UPDATE members + SET search_vector = + setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B') + WHERE id = member_id_val; + + RETURN COALESCE(NEW, OLD); + END + $$ LANGUAGE plpgsql; + """) + + execute(""" + CREATE OR REPLACE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$ + DECLARE + member_id_val uuid; + member_first_name text; + member_last_name text; + member_email text; + member_join_date date; + member_exit_date date; + member_notes text; + member_city text; + member_street text; + member_house_number text; + member_postal_code text; + custom_values_text text; + groups_text text; + BEGIN + FOR member_id_val IN + SELECT COALESCE(NEW.member_id, OLD.member_id) + UNION ALL + SELECT OLD.member_id + WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id + LOOP + SELECT + first_name, + last_name, + email, + join_date, + exit_date, + notes, + city, + street, + house_number, + postal_code + INTO + member_first_name, + member_last_name, + member_email, + member_join_date, + member_exit_date, + member_notes, + member_city, + member_street, + member_house_number, + member_postal_code + FROM members + WHERE id = member_id_val; + + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = member_id_val AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = member_id_val; + + UPDATE members + SET search_vector = + setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B') + WHERE id = member_id_val; + END LOOP; + + RETURN COALESCE(NEW, OLD); + END + $$ LANGUAGE plpgsql; + """) + + # Backfill without country + execute(""" + UPDATE members m + SET search_vector = + setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce( + (SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + FROM custom_field_values + WHERE member_id = m.id AND value IS NOT NULL), + '' + )), 'C') || + setweight(to_tsvector('simple', coalesce( + (SELECT string_agg(g.name, ' ') + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = m.id), + '' + )), 'B') + """) + + alter table(:members) do + remove :country + end + end +end -- 2.47.2 From e7668f1ef40f6f78d16a94e525c4a3dd98625f90 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 09:35:00 +0100 Subject: [PATCH 03/12] docs: adds country --- docs/csv-member-import-v1.md | 29 ++++++++++++++++++++++++----- docs/database-schema-readme.md | 3 ++- docs/database_schema.dbml | 2 ++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/csv-member-import-v1.md b/docs/csv-member-import-v1.md index ed5618b..1a717c6 100644 --- a/docs/csv-member-import-v1.md +++ b/docs/csv-member-import-v1.md @@ -48,7 +48,7 @@ A **basic CSV member import feature** that allows administrators to upload a CSV - Upload CSV file via LiveView file upload - Parse CSV with bilingual header support for core member fields (English/German) - Auto-detect delimiter (`;` or `,`) using header recognition -- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`) +- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`, `country`) - **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning) - Validate each row (required field: `email`) - Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages) @@ -149,13 +149,26 @@ A **basic CSV member import feature** that allows administrators to upload a CSV **v1 Supported Fields:** -**Core Member Fields:** +**Core Member Fields (all importable):** +- `email` / `E-Mail` (required) - `first_name` / `Vorname` (optional) - `last_name` / `Nachname` (optional) -- `email` / `E-Mail` (required) -- `street` / `Straße` (optional) -- `postal_code` / `PLZ` / `Postleitzahl` (optional) +- `join_date` / `Beitrittsdatum` (optional, ISO-8601 date) +- `exit_date` / `Austrittsdatum` (optional, ISO-8601 date) +- `notes` / `Notizen` (optional) +- `country` / `Land` / `Staat` (optional) - `city` / `Stadt` (optional) +- `street` / `Straße` (optional) +- `house_number` / `Hausnummer` / `Nr.` (optional) +- `postal_code` / `PLZ` / `Postleitzahl` (optional) +- `membership_fee_start_date` / `Beitragsbeginn` (optional, ISO-8601 date) + +Address column order in import/export matches the members overview: country, city, street, house number, postal code. + +**Not supported for import (by design):** +- **membership_fee_status** – Computed field (from fee cycles). Not stored; export-only. +- **groups** – Many-to-many relationship. Would require resolving group names to IDs; not in current scope. +- **membership_fee_type_id** – Foreign key; could be added later (e.g. resolve type name to ID). **Custom Fields:** - Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`) @@ -176,9 +189,15 @@ A **basic CSV member import feature** that allows administrators to upload a CSV | `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` | | `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` | | `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` | +| `join_date` | `join date`, `join_date` | `Beitrittsdatum`, `beitritts-datum` | +| `exit_date` | `exit date`, `exit_date` | `Austrittsdatum`, `austritts-datum` | +| `notes` | `notes` | `Notizen`, `bemerkungen` | | `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` | +| `house_number` | `house number`, `house_number`, `house no` | `Hausnummer`, `Nr`, `Nr.`, `Nummer` | | `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` | | `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` | +| `country` | `country` | `Land`, `land`, `Staat`, `staat` | +| `membership_fee_start_date` | `membership fee start date`, `membership_fee_start_date`, `fee start` | `Beitragsbeginn`, `beitrags-beginn` | **Header Normalization (used consistently for both input headers AND mapping variants):** - Trim whitespace diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index de1c158..f58cbea 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -192,6 +192,7 @@ Settings (1) → MembershipFeeType (0..1) - Exit date must be after join date - Phone: `+?[0-9\- ]{6,20}` - Postal code: optional (no format validation) +- Country: optional ### CustomFieldValue System - Maximum one custom field value per custom field per member @@ -240,7 +241,7 @@ Settings (1) → MembershipFeeType (0..1) ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes, group names (from member_groups → groups) -- **Weight C:** city, street, house_number, postal_code, custom_field_values +- **Weight C:** city, street, house_number, postal_code, country, custom_field_values - **Weight D (lowest):** join_date, exit_date ### Group Names in Search diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index e09af58..61da063 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -131,6 +131,7 @@ Table members { street text [null, note: 'Street name'] house_number text [null, note: 'House number'] postal_code text [null, note: '5-digit German postal code'] + country text [null, note: 'Country of residence'] search_vector tsvector [null, note: 'Full-text search index (auto-generated)'] membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type'] membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated'] @@ -189,6 +190,7 @@ Table members { - join_date: cannot be in future - exit_date: must be after join_date (if both present) - postal_code: optional (no format validation) + - country: optional ''' } -- 2.47.2 From f681ca98b2cb1a849aeffc430096efa9216d96c0 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 09:35:27 +0100 Subject: [PATCH 04/12] feat: adds country to member edit --- lib/mv_web/live/member_live/form.ex | 24 ++++++++++++++------- lib/mv_web/live/member_live/index.html.heex | 18 ++++++++++++++++ lib/mv_web/live/member_live/show.ex | 4 ++-- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index f9588c0..37e5db0 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -91,24 +91,31 @@ defmodule MvWeb.MemberLive.Form do - <%!-- Address Row --%> + <%!-- Address: Country, Postal Code, City in one row --%>
-
- <.input field={@form[:street]} label={gettext("Street")} /> -
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} /> +
+ <.input field={@form[:country]} label={gettext("Country")} />
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
-
+
<.input field={@form[:city]} label={gettext("City")} />
+ <%!-- Street and Nr. below --%> +
+
+ <.input field={@form[:street]} label={gettext("Street")} /> +
+
+ <.input field={@form[:house_number]} label={gettext("Nr.")} /> +
+
+ <%!-- Email --%> -
+
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
@@ -606,6 +613,7 @@ defmodule MvWeb.MemberLive.Form do |> extract_form_value(form, :house_number, &to_string/1) |> extract_form_value(form, :postal_code, &to_string/1) |> extract_form_value(form, :city, &to_string/1) + |> extract_form_value(form, :country, &to_string/1) |> extract_form_value(form, :join_date, &format_date_value/1) |> extract_form_value(form, :exit_date, &format_date_value/1) |> extract_form_value(form, :notes, &to_string/1) diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index f8be88d..4fefb29 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -223,6 +223,24 @@ > {member.notes} + <:col + :let={member} + :if={:country in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_country} + field={:country} + label={gettext("Country")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.country} + <:col :let={member} :if={:city in @member_fields_visible} diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 47e8878..72c365d 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -437,8 +437,8 @@ defmodule MvWeb.MemberLive.Show do |> Enum.filter(&(&1 && &1 != "")) |> Enum.join(" ") - [street_part, city_part] - |> Enum.filter(&(&1 != "")) + [member.country, street_part, city_part] + |> Enum.filter(&(&1 && &1 != "")) |> Enum.join(", ") |> case do "" -> nil -- 2.47.2 From 24089781802fc510ce1a6037d39d61aee0389876 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 09:35:49 +0100 Subject: [PATCH 05/12] import: update csv with country --- lib/mv/membership/import/header_mapper.ex | 63 ++++++++++++++++++++--- lib/mv/membership/import/member_csv.ex | 22 +++++++- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/lib/mv/membership/import/header_mapper.ex b/lib/mv/membership/import/header_mapper.ex index 709e156..d96d96e 100644 --- a/lib/mv/membership/import/header_mapper.ex +++ b/lib/mv/membership/import/header_mapper.ex @@ -17,15 +17,24 @@ defmodule Mv.Membership.Import.HeaderMapper do ## Member Field Mapping - Maps CSV headers to canonical member fields: - - `email` (required) - - `first_name` (optional) - - `last_name` (optional) - - `street` (optional) - - `postal_code` (optional) - - `city` (optional) + Maps CSV headers to canonical member fields (same as `Mv.Constants.member_fields()` for + importable attributes). All DB-backed member attributes can be imported. - Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname"). + - `email` (required) + - `first_name`, `last_name` (optional) + - `join_date`, `exit_date` (optional, ISO-8601 date) + - `notes` (optional) + - `country`, `city`, `street`, `house_number`, `postal_code` (optional) + - `membership_fee_start_date` (optional, ISO-8601 date) + + Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum"). + + ## 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. ## Custom Field Detection @@ -75,11 +84,37 @@ defmodule Mv.Membership.Import.HeaderMapper do "nachname", "familienname" ], + join_date: [ + "join date", + "join_date", + "beitrittsdatum", + "beitritts-datum" + ], + exit_date: [ + "exit date", + "exit_date", + "austrittsdatum", + "austritts-datum" + ], + notes: [ + "notes", + "notizen", + "bemerkungen" + ], street: [ "street", "address", "strasse" ], + house_number: [ + "house number", + "house_number", + "house no", + "hausnummer", + "nr", + "nr.", + "nummer" + ], postal_code: [ "postal code", "postal_code", @@ -93,6 +128,18 @@ defmodule Mv.Membership.Import.HeaderMapper do "town", "stadt", "ort" + ], + country: [ + "country", + "land", + "staat" + ], + membership_fee_start_date: [ + "membership fee start date", + "membership_fee_start_date", + "fee start", + "beitragsbeginn", + "beitrags-beginn" ] } diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index c967bf5..23e0d93 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -549,9 +549,12 @@ defmodule Mv.Membership.Import.MemberCSV do line_number, actor ) do + # Convert empty strings to nil for date fields so Ash accepts them + member_attrs = sanitize_date_fields(trimmed_member_attrs) + # Create member with custom field values member_attrs_with_cf = - trimmed_member_attrs + member_attrs |> Map.put(:custom_field_values, custom_field_values) # Only include custom_field_values if not empty @@ -793,6 +796,23 @@ defmodule Mv.Membership.Import.MemberCSV do end) end + # Converts empty strings to nil for date fields so Ash can accept them + @date_fields [:join_date, :exit_date, :membership_fee_start_date] + + defp sanitize_date_fields(attrs) when is_map(attrs) do + Enum.reduce(@date_fields, attrs, fn field, acc -> + put_date_field(acc, field, Map.get(acc, field)) + end) + end + + defp put_date_field(acc, field, ""), do: Map.put(acc, field, nil) + + defp put_date_field(acc, field, val) when is_binary(val) do + if String.trim(val) == "", do: Map.put(acc, field, nil), else: acc + end + + defp put_date_field(acc, _field, _), do: acc + # Formats Ash errors into MemberCSV.Error structs defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number, email) do # Try to find email-related errors first (for better error messages) -- 2.47.2 From 3891c33204f8e411c48f3d18f8a769cbfa666d7c Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 09:36:24 +0100 Subject: [PATCH 06/12] i18n: update translation --- priv/gettext/de/LC_MESSAGES/default.po | 22 +++++++--------------- priv/gettext/default.pot | 7 +++++++ priv/gettext/en/LC_MESSAGES/default.po | 20 ++++++-------------- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 31de1c2..442e308 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2579,7 +2579,7 @@ msgstr "Erstellt am:" #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export" -msgstr "Nach CSV exportieren" +msgstr "Export" #: lib/mv_web/controllers/member_pdf_export_controller.ex #, elixir-autogen, elixir-format @@ -2622,17 +2622,9 @@ msgstr "Import" msgid "Value type cannot be changed after creation" msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "Mitglieder exportieren (CSV)" - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar." - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten." +#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format +msgid "Country" +msgstr "Land" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0ab0302..0f78ff0 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2622,3 +2622,10 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Value type cannot be changed after creation" msgstr "" + +#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format +msgid "Country" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 199173b..6a7b629 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2623,17 +2623,9 @@ msgstr "" msgid "Value type cannot be changed after creation" msgstr "" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "" - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "" - -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ msgstr "" +#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format +msgid "Country" +msgstr "" -- 2.47.2 From 9fc8c3b74a790207b47fe7bda73c23b88d886774 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 09:36:42 +0100 Subject: [PATCH 07/12] test: updated for country --- .../membership/import/header_mapper_test.exs | 47 +++++++++++++++---- .../components/sort_header_component_test.exs | 2 + .../live/global_settings_live_config_test.exs | 4 +- test/mv_web/live/import_live_test.exs | 8 ++-- .../index_member_fields_display_test.exs | 1 + test/mv_web/member_live/index_test.exs | 1 + 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/test/mv/membership/import/header_mapper_test.exs b/test/mv/membership/import/header_mapper_test.exs index 5e7efbd..2f4fcad 100644 --- a/test/mv/membership/import/header_mapper_test.exs +++ b/test/mv/membership/import/header_mapper_test.exs @@ -207,9 +207,15 @@ defmodule Mv.Membership.Import.HeaderMapperTest do "Email", "First Name", "Last Name", + "Join Date", + "Exit Date", + "Notes", + "Country", + "City", "Street", + "House Number", "Postal Code", - "City" + "Membership Fee Start Date" ] assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} = @@ -218,15 +224,34 @@ defmodule Mv.Membership.Import.HeaderMapperTest do assert member_map[:email] == 0 assert member_map[:first_name] == 1 assert member_map[:last_name] == 2 - assert member_map[:street] == 3 - assert member_map[:postal_code] == 4 - assert member_map[:city] == 5 + assert member_map[:join_date] == 3 + assert member_map[:exit_date] == 4 + assert member_map[:notes] == 5 + assert member_map[:country] == 6 + assert member_map[:city] == 7 + assert member_map[:street] == 8 + assert member_map[:house_number] == 9 + assert member_map[:postal_code] == 10 + assert member_map[:membership_fee_start_date] == 11 assert custom_map == %{} assert unknown == [] end test "maps German member field variants" do - headers = ["E-Mail", "Vorname", "Nachname", "Straße", "PLZ", "Stadt"] + headers = [ + "E-Mail", + "Vorname", + "Nachname", + "Beitrittsdatum", + "Austrittsdatum", + "Notizen", + "Land", + "Stadt", + "Straße", + "Hausnummer", + "PLZ", + "Beitragsbeginn" + ] assert {:ok, %{member: member_map, custom: custom_map, unknown: unknown}} = HeaderMapper.build_maps(headers, []) @@ -234,9 +259,15 @@ defmodule Mv.Membership.Import.HeaderMapperTest do assert member_map[:email] == 0 assert member_map[:first_name] == 1 assert member_map[:last_name] == 2 - assert member_map[:street] == 3 - assert member_map[:postal_code] == 4 - assert member_map[:city] == 5 + assert member_map[:join_date] == 3 + assert member_map[:exit_date] == 4 + assert member_map[:notes] == 5 + assert member_map[:country] == 6 + assert member_map[:city] == 7 + assert member_map[:street] == 8 + assert member_map[:house_number] == 9 + assert member_map[:postal_code] == 10 + assert member_map[:membership_fee_start_date] == 11 assert custom_map == %{} assert unknown == [] end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index bdde4ae..d8247dc 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -24,6 +24,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do :house_number, :postal_code, :city, + :country, :join_date ] @@ -100,6 +101,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do assert has_element?(view, "[data-testid='street'] .opacity-40") assert has_element?(view, "[data-testid='house_number'] .opacity-40") assert has_element?(view, "[data-testid='postal_code'] .opacity-40") + assert has_element?(view, "[data-testid='country'] .opacity-40") assert has_element?(view, "[data-testid='join_date'] .opacity-40") end diff --git a/test/mv_web/live/global_settings_live_config_test.exs b/test/mv_web/live/global_settings_live_config_test.exs index 9ac75fd..c82f292 100644 --- a/test/mv_web/live/global_settings_live_config_test.exs +++ b/test/mv_web/live/global_settings_live_config_test.exs @@ -45,11 +45,11 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do {:ok, view, _html} = live(conn, ~p"/admin/import") # Generate CSV with 501 rows (exceeding custom limit of 500) - header = "first_name;last_name;email;street;postal_code;city\n" + header = "first_name;last_name;email;country;city;street;postal_code\n" rows = for i <- 1..501 do - "Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n" + "Row#{i};Last#{i};email#{i}@example.com;Country#{i};City#{i};Street#{i};12345\n" end large_csv = header <> Enum.join(rows) diff --git a/test/mv_web/live/import_live_test.exs b/test/mv_web/live/import_live_test.exs index 48fbb11..35edd23 100644 --- a/test/mv_web/live/import_live_test.exs +++ b/test/mv_web/live/import_live_test.exs @@ -136,10 +136,10 @@ defmodule MvWeb.ImportLiveTest do test "error list is capped and truncation message is shown", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") - header = "first_name;last_name;email;street;postal_code;city\n" + header = "first_name;last_name;email;country;city;street;postal_code\n" invalid_rows = - for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n" + 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) @@ -154,11 +154,11 @@ defmodule MvWeb.ImportLiveTest do test "row limit is enforced (1001 rows rejected)", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import") - header = "first_name;last_name;email;street;postal_code;city\n" + header = "first_name;last_name;email;country;city;street;postal_code\n" rows = for i <- 1..1001 do - "Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n" + "Row#{i};Last#{i};email#{i}@example.com;Country#{i};City#{i};Street#{i};12345\n" end upload_csv_file(view, header <> Enum.join(rows), "too_many_rows.csv") diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index fe33bb4..f3dadac 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -16,6 +16,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do house_number: "123", postal_code: "12345", city: "Berlin", + country: "Germany", join_date: ~D[2020-01-15] }, actor: system_actor diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 53a2815..5246d80 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -162,6 +162,7 @@ defmodule MvWeb.MemberLive.IndexTest do :house_number, :postal_code, :city, + :country, :join_date ] -- 2.47.2 From e8bcd88ee12e01282a523f8d55b4c0c2617a42e1 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 09:37:01 +0100 Subject: [PATCH 08/12] chore: updated template for csv --- priv/static/templates/member_import_de.csv | 4 ++-- priv/static/templates/member_import_en.csv | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/priv/static/templates/member_import_de.csv b/priv/static/templates/member_import_de.csv index 3bcbeb5..f65e042 100644 --- a/priv/static/templates/member_import_de.csv +++ b/priv/static/templates/member_import_de.csv @@ -1,2 +1,2 @@ -Vorname;Nachname;E-Mail;Straße;PLZ;Stadt -Max;Mustermann;max.mustermann@example.com;Hauptstraße;10115;Berlin +Vorname;Nachname;E-Mail;Land;Stadt;Straße;Hausnummer;PLZ;Beitrittsdatum;Austrittsdatum;Notizen;Beitragsbeginn +Max;Mustermann;max.mustermann@example.com;Deutschland;Berlin;Hauptstraße;12;10115;2020-01-15;;; diff --git a/priv/static/templates/member_import_en.csv b/priv/static/templates/member_import_en.csv index d4e67f3..c261279 100644 --- a/priv/static/templates/member_import_en.csv +++ b/priv/static/templates/member_import_en.csv @@ -1,2 +1,2 @@ -first_name;last_name;email;street;postal_code;city -John;Doe;john.doe@example.com;Main Street;12345;Berlin +first_name;last_name;email;country;city;street;house_number;postal_code;join_date;exit_date;notes;membership_fee_start_date +John;Doe;john.doe@example.com;Germany;Berlin;Main Street;1a;12345;2020-01-15;;; -- 2.47.2 From 8fd2ee067ebb59818715dc154ee53cd8c15f5371 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 15:07:34 +0100 Subject: [PATCH 09/12] style: udate csv import --- lib/mv_web/live/import_live.ex | 8 +++++++- lib/mv_web/live/import_live/components.ex | 20 +++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index e97ecd7..2b2a58f 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -92,7 +92,13 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> - <.form_section title={gettext("Import Members (CSV)")}> + <.header> + {gettext("Import Members (CSV)")} + <:subtitle> + {gettext("Import members from CSV files.")} + + + <.form_section title={gettext("Datei auswählen")}> diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 69354bd..9d5db4f 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -20,12 +20,12 @@ defmodule MvWeb.ImportLive.Components do """ def custom_fields_notice(assigns) do ~H""" -
+
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />

{gettext( - "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." + "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." )}

@@ -48,7 +48,7 @@ defmodule MvWeb.ImportLive.Components do def template_links(assigns) do ~H"""

-

+

{gettext("Download CSV templates:")}

    @@ -88,22 +88,20 @@ defmodule MvWeb.ImportLive.Components do phx-submit="start_import" data-testid="csv-upload-form" > -
    -
    + <.button type="submit" -- 2.47.2 From aaa897c8dcc322e4507fe24172543b141d962cd4 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 15:27:12 +0100 Subject: [PATCH 10/12] style: restyle PDF export --- lib/mv_web/live/import_live.ex | 10 +++---- lib/mv_web/live/import_live/components.ex | 2 +- priv/pdf_templates/members_export.typ | 32 ++++++++++++++++++++--- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 2b2a58f..2e32f8e 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -93,11 +93,11 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> <.header> - {gettext("Import Members (CSV)")} - <:subtitle> - {gettext("Import members from CSV files.")} - - + {gettext("Import Members (CSV)")} + <:subtitle> + {gettext("Import members from CSV files.")} + + <.form_section title={gettext("Datei auswählen")}> diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 9d5db4f..93dc154 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -25,7 +25,7 @@ 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 with a warning. Groups and membership fees are not supported for import." )}

    diff --git a/priv/pdf_templates/members_export.typ b/priv/pdf_templates/members_export.typ index 5dca208..33b793e 100644 --- a/priv/pdf_templates/members_export.typ +++ b/priv/pdf_templates/members_export.typ @@ -9,7 +9,13 @@ #set page( paper: "a4", flipped: true, - margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm) + margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm), + numbering: "1", + footer: context [ + #set text(size: 8pt) + #set align(center) + #counter(page).display("1 / 1", both: true) + ] ) #set text(size: 9pt, hyphenate: true) @@ -58,7 +64,6 @@ #let start = fixed_count + chunk_index * max_dynamic_cols #let page_cols = fixed_cols + dyn_cols_chunk - #let headers = page_cols.map(c => c.at("label", default: "")) // widths: first two columns fixed (32mm, 42mm), rest distributed as 1fr #let widths = ( @@ -67,9 +72,9 @@ ..((1fr,) * dyn_count) ) - #let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h]) + #let header_cells = page_cols.map(c => text(weight: "bold", size: 9pt)[#c.at("label", default: "")]) - // Body cells (row-major), nur die Spalten dieses Chunks + // Body cells (row-major), only columns of this chunk #let body_cells = ( rows .map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count)) @@ -77,8 +82,27 @@ .flatten() ) + // Thinner grid for body; thicker vertical line between column 2 and 3; header and outer borders thick + #let thin_stroke = 0.3pt + black + #let thick_sep = 1.5pt + black + #let thick_stroke = 1pt + black + #let last_x = fixed_count + dyn_count - 1 + #let last_y = rows.len() + #let stroke_fn = (x, y) => { + let top = if y == 0 or y == 1 { thick_stroke } else { thin_stroke } + let bottom = if y == 0 or y == last_y { thick_stroke } else { thin_stroke } + let left = if x == 0 { thick_stroke } else if x == 2 { thick_sep } else { thin_stroke } + let right = if x == last_x { thick_stroke } else { thin_stroke } + (top: top, bottom: bottom, left: left, right: right) + } + + // Light gray background for first two columns (first_name, last_name) + #let fill_fn = (x, y) => if x < 2 { rgb("f2f2f2") } else { none } + #table( columns: widths, + stroke: stroke_fn, + fill: fill_fn, table.header(..header_cells), ..body_cells, ) -- 2.47.2 From 6417958cccfca46c78c034b72c9465273c008353 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 15:38:20 +0100 Subject: [PATCH 11/12] i18n: Update translations --- lib/mv_web/live/import_live.ex | 4 +-- lib/mv_web/live/import_live/components.ex | 2 +- priv/gettext/de/LC_MESSAGES/default.po | 35 ++++++++++++++++------- priv/gettext/default.pot | 30 ++++++++++++------- priv/gettext/en/LC_MESSAGES/default.po | 30 ++++++++++++------- 5 files changed, 68 insertions(+), 33 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 2e32f8e..894fe36 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -93,12 +93,12 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> <.header> - {gettext("Import Members (CSV)")} + {gettext("Import Members")} <:subtitle> {gettext("Import members from CSV files.")} - <.form_section title={gettext("Datei auswählen")}> + <.form_section title={gettext("Choose CSV file")}> diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex index 93dc154..9d5db4f 100644 --- a/lib/mv_web/live/import_live/components.ex +++ b/lib/mv_web/live/import_live/components.ex @@ -25,7 +25,7 @@ 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 with a warning. 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. Groups and membership fees are not supported for import." )}

    diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index ea9422d..b7f1144 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2049,11 +2049,6 @@ msgstr "Fehlgeschlagen: %{count} Zeile(n)" msgid "German Template" msgstr "Deutsche Vorlage" -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "Import Members (CSV)" -msgstr "Mitglieder importieren (CSV)" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import Results" @@ -2382,11 +2377,6 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." msgid "Manage Member Data" msgstr "Mitgliederdaten verwalten" -#: 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, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." -msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert." - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" @@ -2927,3 +2917,28 @@ msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Beitragsart" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import members from CSV files." +msgstr "Miglieder aus CSV Dateien importieren." + +#: 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 der Beitragsstatus kann nicht importiert werden." + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Choose CSV file" +msgstr "CSV Datei auswählen" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Import Members" +msgstr "Mitglieder importieren (CSV)" + +#~ #: lib/mv_web/live/import_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Datei auswählen" +#~ msgstr "" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 84ad2a0..81d6d94 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2050,11 +2050,6 @@ msgstr "" msgid "German Template" msgstr "" -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "Import Members (CSV)" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import Results" @@ -2383,11 +2378,6 @@ msgstr "" msgid "Manage Member Data" 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, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." -msgstr "" - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format msgid "Export members to CSV" @@ -2927,3 +2917,23 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import members from CSV files." +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/import_live.ex +#, elixir-autogen, elixir-format +msgid "Choose CSV file" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import Members" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9928e76..3121959 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2050,11 +2050,6 @@ msgstr "" msgid "German Template" msgstr "" -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "Import Members (CSV)" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Import Results" @@ -2383,11 +2378,6 @@ msgstr "" msgid "Manage Member Data" 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, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." -msgstr "" - #: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" @@ -2927,3 +2917,23 @@ msgstr "Required for Vereinfacht integration and cannot be disabled." #, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Fee Type" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "Import members from CSV files." +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/import_live.ex +#, elixir-autogen, elixir-format +msgid "Choose CSV file" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Import Members" +msgstr "" -- 2.47.2 From c62b105518745e60c6339b7daf4dd1232b911aa3 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 24 Feb 2026 16:00:46 +0100 Subject: [PATCH 12/12] test: updated --- lib/mv_web/live/import_live.ex | 30 ++++++++++++++------------- test/mv_web/live/import_live_test.exs | 3 +-- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 894fe36..4e172ed 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -92,20 +92,22 @@ defmodule MvWeb.ImportLive do <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%!-- CSV Import Section --%> - <.header> - {gettext("Import Members")} - <:subtitle> - {gettext("Import members from CSV files.")} - - - <.form_section title={gettext("Choose CSV file")}> - - - - <%= if @import_status == :running or @import_status == :done or @import_status == :error do %> - - <% end %> - +

    + <.header> + {gettext("Import Members")} + <:subtitle> + {gettext("Import members from CSV files.")} + + + <.form_section title={gettext("Choose CSV file")}> + + + + <%= if @import_status == :running or @import_status == :done or @import_status == :error do %> + + <% end %> + +
    <% else %>