diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex
new file mode 100644
index 0000000..5f771cc
--- /dev/null
+++ b/lib/mv/membership/member_export.ex
@@ -0,0 +1,344 @@
+defmodule Mv.Membership.MemberExport do
+ @moduledoc """
+ Builds member list and column specs for CSV export.
+
+ Used by `MvWeb.MemberExportController`. Does not perform translations;
+ the controller applies headers (e.g. via `MemberFields.label` / gettext)
+ and sends the download.
+ """
+
+ require Ash.Query
+ import Ash.Expr
+
+ alias Mv.Membership.CustomField
+ alias Mv.Membership.Member
+ alias Mv.Membership.MemberExportSort
+ alias MvWeb.MemberLive.Index.MembershipFeeStatus
+
+ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
+ ["membership_fee_status", "payment_status"]
+ @computed_export_fields ["membership_fee_status", "payment_status"]
+ @custom_field_prefix Mv.Constants.custom_field_prefix()
+ @domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+
+ @doc """
+ Fetches members and column specs for export.
+
+ - `actor` - Ash actor (e.g. current user)
+ - `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
+
+ Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
+ Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
+ the controller adds `:header` and optional computed columns to members before CSV export.
+ """
+ @spec fetch(struct(), map()) ::
+ {:ok, [struct()], [map()]} | {:error, :forbidden}
+ def fetch(actor, parsed) do
+ custom_field_ids_union =
+ (parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
+
+ with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
+ {:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
+ column_specs = build_column_specs(parsed, custom_fields_by_id)
+ {:ok, members, column_specs}
+ end
+ end
+
+ defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
+
+ defp load_custom_fields_by_id(custom_field_ids, actor) do
+ query =
+ CustomField
+ |> Ash.Query.filter(expr(id in ^custom_field_ids))
+ |> Ash.Query.select([:id, :name, :value_type])
+
+ case Ash.read(query, actor: actor) do
+ {:ok, custom_fields} ->
+ by_id =
+ Enum.reduce(custom_field_ids, %{}, fn id, acc ->
+ case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
+ nil -> acc
+ cf -> Map.put(acc, id, cf)
+ end
+ end)
+
+ {:ok, by_id}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:error, :forbidden}
+ end
+ end
+
+ defp build_column_specs(parsed, custom_fields_by_id) do
+ member_specs =
+ Enum.map(parsed.member_fields, fn f ->
+ if f in parsed.selectable_member_fields do
+ %{kind: :member_field, key: f}
+ else
+ %{kind: :computed, key: String.to_existing_atom(f)}
+ end
+ end)
+
+ custom_specs =
+ parsed.custom_field_ids
+ |> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
+
+ member_specs ++ custom_specs
+ end
+
+ defp load_members(actor, parsed, custom_fields_by_id) do
+ select_fields =
+ [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
+
+ custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
+
+ need_cycles =
+ parsed.show_current_cycle or parsed.cycle_status_filter != nil or
+ parsed.computed_fields != []
+
+ query =
+ Member
+ |> Ash.Query.new()
+ |> Ash.Query.select(select_fields)
+ |> load_custom_field_values_query(custom_field_ids_union)
+ |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
+
+ query =
+ if parsed.selected_ids != [] do
+ Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
+ else
+ query
+ |> apply_search(parsed.query)
+ |> then(fn q ->
+ {q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
+ q
+ end)
+ end
+
+ case Ash.read(query, actor: actor) do
+ {:ok, members} ->
+ members =
+ if parsed.selected_ids == [] do
+ members
+ |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
+ |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ parsed.boolean_filters || %{},
+ Map.values(custom_fields_by_id)
+ )
+ else
+ members
+ end
+
+ members =
+ if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
+ sort_members_by_custom_field(
+ members,
+ parsed.sort_field,
+ parsed.sort_order,
+ Map.values(custom_fields_by_id)
+ )
+ else
+ members
+ end
+
+ {:ok, members}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:error, :forbidden}
+ end
+ end
+
+ defp load_custom_field_values_query(query, []), do: query
+
+ defp load_custom_field_values_query(query, custom_field_ids) do
+ cfv_query =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
+ |> Ash.Query.load(custom_field: [:id, :name, :value_type])
+
+ Ash.Query.load(query, custom_field_values: cfv_query)
+ end
+
+ defp apply_search(query, nil), do: query
+ defp apply_search(query, ""), do: query
+
+ defp apply_search(query, q) when is_binary(q) do
+ if String.trim(q) != "" do
+ Member.fuzzy_search(query, %{query: q})
+ else
+ query
+ end
+ end
+
+ defp maybe_sort(query, nil, _order), do: {query, false}
+ defp maybe_sort(query, _field, nil), do: {query, false}
+
+ defp maybe_sort(query, field, order) when is_binary(field) do
+ if custom_field_sort?(field) do
+ {query, true}
+ else
+ field_atom = String.to_existing_atom(field)
+
+ if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
+ {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
+ else
+ {query, false}
+ end
+ end
+ rescue
+ ArgumentError -> {query, false}
+ end
+
+ defp sort_after_load?(field) when is_binary(field),
+ do: String.starts_with?(field, @custom_field_prefix)
+
+ defp sort_after_load?(_), do: false
+
+ defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
+ do: []
+
+ defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
+ id_str = String.trim_leading(field, @custom_field_prefix)
+ custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
+ if is_nil(custom_field), do: members
+
+ key_fn = fn member ->
+ cfv = find_cfv(member, custom_field)
+ raw = if cfv, do: cfv.value, else: nil
+ MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
+ end
+
+ members
+ |> Enum.map(fn m -> {m, key_fn.(m)} end)
+ |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
+ |> Enum.map(fn {m, _} -> m end)
+ end
+
+ defp find_cfv(member, custom_field) do
+ (member.custom_field_values || [])
+ |> Enum.find(fn cfv ->
+ to_string(cfv.custom_field_id) == to_string(custom_field.id) or
+ (Map.get(cfv, :custom_field) &&
+ to_string(cfv.custom_field.id) == to_string(custom_field.id))
+ end)
+ end
+
+ defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
+
+ defp maybe_load_cycles(query, false, _show_current), do: query
+
+ defp maybe_load_cycles(query, true, show_current) do
+ MembershipFeeStatus.load_cycles_for_members(query, show_current)
+ end
+
+ defp apply_cycle_status_filter(members, nil, _show_current), do: members
+
+ defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
+ MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
+ end
+
+ defp apply_cycle_status_filter(members, _status, _show_current), do: members
+
+ # Called by controller to build parsed map from raw params (kept here so controller stays thin)
+ @doc """
+ Parses and validates export params (from JSON payload).
+
+ Returns a map with :selected_ids, :member_fields, :selectable_member_fields,
+ :computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
+ :show_current_cycle, :cycle_status_filter, :boolean_filters.
+ """
+ @spec parse_params(map()) :: map()
+ def parse_params(params) do
+ member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
+ {selectable_member_fields, computed_fields} = split_member_fields(member_fields)
+
+ %{
+ selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
+ member_fields: member_fields,
+ selectable_member_fields: selectable_member_fields,
+ computed_fields: computed_fields,
+ custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
+ query: extract_string(params, "query"),
+ sort_field: extract_string(params, "sort_field"),
+ sort_order: extract_sort_order(params),
+ show_current_cycle: extract_boolean(params, "show_current_cycle"),
+ cycle_status_filter: extract_cycle_status_filter(params),
+ boolean_filters: extract_boolean_filters(params)
+ }
+ end
+
+ defp split_member_fields(member_fields) do
+ selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end)
+ computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
+ {selectable, computed}
+ end
+
+ defp extract_boolean(params, key) do
+ case Map.get(params, key) do
+ true -> true
+ "true" -> true
+ _ -> false
+ end
+ end
+
+ defp extract_cycle_status_filter(params) do
+ case Map.get(params, "cycle_status_filter") do
+ "paid" -> :paid
+ "unpaid" -> :unpaid
+ _ -> nil
+ end
+ end
+
+ defp extract_boolean_filters(params) do
+ case Map.get(params, "boolean_filters") do
+ map when is_map(map) ->
+ map
+ |> Enum.filter(fn {k, v} -> is_binary(k) and is_boolean(v) end)
+ |> Enum.filter(fn {k, _} -> match?({:ok, _}, Ecto.UUID.cast(k)) end)
+ |> Enum.into(%{})
+
+ _ ->
+ %{}
+ end
+ end
+
+ defp extract_list(params, key) do
+ case Map.get(params, key) do
+ list when is_list(list) -> list
+ _ -> []
+ end
+ end
+
+ defp extract_string(params, key) do
+ case Map.get(params, key) do
+ s when is_binary(s) -> s
+ _ -> nil
+ end
+ end
+
+ defp extract_sort_order(params) do
+ case Map.get(params, "sort_order") do
+ "asc" -> "asc"
+ "desc" -> "desc"
+ _ -> nil
+ end
+ end
+
+ defp filter_allowed_member_fields(field_list) do
+ allowlist = MapSet.new(@member_fields_allowlist)
+
+ field_list
+ |> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
+ |> Enum.uniq()
+ end
+
+ defp filter_valid_uuids(id_list) when is_list(id_list) do
+ id_list
+ |> Enum.filter(fn id ->
+ is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
+ end)
+ |> Enum.uniq()
+ end
+end
diff --git a/lib/mv/membership/member_export_sort.ex b/lib/mv/membership/member_export_sort.ex
new file mode 100644
index 0000000..324fb75
--- /dev/null
+++ b/lib/mv/membership/member_export_sort.ex
@@ -0,0 +1,44 @@
+defmodule Mv.Membership.MemberExportSort do
+ @moduledoc """
+ Type-stable sort keys for CSV export custom-field sorting.
+
+ Used only by `MvWeb.MemberExportController` when sorting members by a custom field
+ after load. Nil values sort last in ascending order and first in descending order.
+ String and email comparison is case-insensitive.
+ """
+ @doc """
+ Returns a comparable sort key for (value_type, value).
+
+ - Nil: rank 1 so that in asc order nil sorts last, in desc nil sorts first.
+ - date: chronological (ISO8601 string).
+ - boolean: false < true (0 < 1).
+ - integer: numerical order.
+ - string / email: case-insensitive (downcased).
+
+ Handles Ash.Union in value; value_type is the custom field's value_type atom.
+ """
+ @spec custom_field_sort_key(:string | :integer | :boolean | :date | :email, term()) ::
+ {0 | 1, term()}
+ def custom_field_sort_key(_value_type, nil), do: {1, nil}
+
+ def custom_field_sort_key(value_type, %Ash.Union{value: value, type: _type}) do
+ custom_field_sort_key(value_type, value)
+ end
+
+ def custom_field_sort_key(:date, %Date{} = d), do: {0, Date.to_iso8601(d)}
+ def custom_field_sort_key(:boolean, true), do: {0, 1}
+ def custom_field_sort_key(:boolean, false), do: {0, 0}
+ def custom_field_sort_key(:integer, v) when is_integer(v), do: {0, v}
+ def custom_field_sort_key(:string, v) when is_binary(v), do: {0, String.downcase(v)}
+ def custom_field_sort_key(:email, v) when is_binary(v), do: {0, String.downcase(v)}
+ def custom_field_sort_key(_value_type, v), do: {0, to_string(v)}
+
+ @doc """
+ Returns true if key_a should sort before key_b for the given order.
+
+ "asc" -> nil last; "desc" -> nil first. No reverse of list needed.
+ """
+ @spec key_lt({0 | 1, term()}, {0 | 1, term()}, String.t()) :: boolean()
+ def key_lt(key_a, key_b, "asc"), do: key_a < key_b
+ def key_lt(key_a, key_b, "desc"), do: key_b < key_a
+end
diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex
index 6eab399..a0fd463 100644
--- a/lib/mv/membership/members_csv.ex
+++ b/lib/mv/membership/members_csv.ex
@@ -2,9 +2,11 @@ defmodule Mv.Membership.MembersCSV do
@moduledoc """
Exports members to CSV (RFC 4180) as iodata.
- Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings;
- custom field values use the same formatting logic as the member overview (neutral formatter).
- Column order for custom fields follows the key order of the `custom_fields_by_id` map.
+ Uses a column-based API: `export(members, columns)` where each column has
+ `header` (display string, e.g. from Web layer), `kind` (:member_field | :custom_field | :computed),
+ and `key` (member attribute name, custom_field id, or computed key). Custom field columns
+ include a `custom_field` struct for value formatting. Domain code does not use Gettext;
+ headers and computed values come from the caller (e.g. controller).
"""
alias Mv.Membership.CustomFieldValueFormatter
alias NimbleCSV.RFC4180
@@ -12,57 +14,82 @@ defmodule Mv.Membership.MembersCSV do
@doc """
Exports a list of members to CSV iodata.
- - `members` - List of member structs (with optional `custom_field_values` loaded)
- - `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`)
- - `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order.
+ - `members` - List of member structs or maps (with optional `custom_field_values` loaded)
+ - `columns` - List of column specs: `%{header: String.t(), kind: :member_field | :custom_field | :computed, key: term()}`
+ For `:custom_field`, also pass `custom_field: %CustomField{}`. Header is used as-is (localized by caller).
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()],
- [String.t()],
- %{optional(String.t() | Ecto.UUID.t()) => struct()}
- ) :: iodata()
- def export(members, member_fields, custom_fields_by_id) when is_list(members) do
- custom_entries = custom_field_entries(custom_fields_by_id)
- header = build_header(member_fields, custom_entries)
- rows = Enum.map(members, &build_row(&1, member_fields, custom_entries))
+ @spec export([struct() | map()], [map()]) :: iodata()
+ def export(members, columns) when is_list(members) do
+ header = build_header(columns)
+ rows = Enum.map(members, fn member -> build_row(member, columns) end)
RFC4180.dump_to_iodata([header | rows])
end
- defp custom_field_entries(by_id) when is_map(by_id) do
- Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end)
+ defp build_header(columns) do
+ columns
+ |> Enum.map(fn col -> col.header end)
+ |> Enum.map(&safe_cell/1)
end
- defp build_header(member_fields, custom_entries) do
- member_headers = member_fields
- custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end)
- member_headers ++ custom_headers
+ defp build_row(member, columns) do
+ columns
+ |> Enum.map(fn col -> cell_value(member, col) end)
+ |> Enum.map(&safe_cell/1)
end
- defp build_row(member, member_fields, custom_entries) do
- member_cells = Enum.map(member_fields, &format_member_field(member, &1))
-
- custom_cells =
- Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end)
-
- member_cells ++ custom_cells
- end
-
- defp format_member_field(member, field_name) do
- key = member_field_key(field_name)
- value = Map.get(member, key)
+ defp cell_value(member, %{kind: :member_field, key: key}) do
+ key_atom = key_to_atom(key)
+ value = Map.get(member, key_atom)
format_member_value(value)
end
- defp member_field_key(field_name) when is_binary(field_name) do
+ defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}) do
+ cfv = get_cfv_by_id(member, id)
+
+ if cfv,
+ do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf),
+ else: ""
+ end
+
+ defp cell_value(member, %{kind: :computed, key: key}) do
+ value = Map.get(member, key_to_atom(key))
+ if is_binary(value), do: value, else: ""
+ end
+
+ defp key_to_atom(k) when is_atom(k), do: k
+
+ defp key_to_atom(k) when is_binary(k) do
try do
- String.to_existing_atom(field_name)
+ String.to_existing_atom(k)
rescue
- ArgumentError -> field_name
+ ArgumentError -> k
end
end
+ defp get_cfv_by_id(member, id) do
+ values =
+ case Map.get(member, :custom_field_values) do
+ v when is_list(v) -> v
+ _ -> []
+ end
+
+ id_str = to_string(id)
+
+ Enum.find(values, fn cfv ->
+ to_string(cfv.custom_field_id) == id_str or
+ (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
+ end)
+ end
+
+ @doc false
+ @spec safe_cell(String.t()) :: String.t()
+ def safe_cell(s) when is_binary(s) do
+ if String.starts_with?(s, ["=", "+", "-", "@", "\t"]), do: "'" <> s, else: s
+ end
+
defp format_member_value(nil), do: ""
defp format_member_value(true), do: "true"
defp format_member_value(false), do: "false"
@@ -70,22 +97,4 @@ defmodule Mv.Membership.MembersCSV do
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
-
- defp format_custom_field(member, custom_field_id, custom_field) do
- cfv = find_custom_field_value(member, custom_field_id)
-
- if cfv,
- do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field),
- else: ""
- end
-
- defp find_custom_field_value(member, custom_field_id) do
- values = Map.get(member, :custom_field_values) || []
- id_str = to_string(custom_field_id)
-
- Enum.find(values, fn cfv ->
- to_string(cfv.custom_field_id) == id_str or
- (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
- end)
- end
end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 45bcae0..e74020c 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -178,7 +178,8 @@ defmodule MvWeb.CoreComponents do
aria-haspopup="menu"
aria-expanded={@open}
aria-controls={@id}
- class="btn"
+ aria-label={@button_label}
+ class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid="dropdown-button"
@@ -232,11 +233,12 @@ defmodule MvWeb.CoreComponents do
diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex
index cad32a2..57ce630 100644
--- a/lib/mv_web/controllers/member_export_controller.ex
+++ b/lib/mv_web/controllers/member_export_controller.ex
@@ -61,6 +61,7 @@ defmodule MvWeb.MemberExportController do
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
+ computed_fields: filter_existing_atoms(extract_list(params, "computed_fields")),
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"),
@@ -68,6 +69,20 @@ defmodule MvWeb.MemberExportController do
}
end
+ defp filter_existing_atoms(list) when is_list(list) do
+ list
+ |> Enum.filter(&is_binary/1)
+ |> Enum.filter(fn name ->
+ try do
+ _ = String.to_existing_atom(name)
+ true
+ rescue
+ ArgumentError -> false
+ end
+ end)
+ |> Enum.uniq()
+ end
+
defp extract_list(params, key) do
case Map.get(params, key) do
list when is_list(list) -> list
@@ -107,9 +122,16 @@ defmodule MvWeb.MemberExportController do
end
defp run_export(conn, actor, parsed) do
+ # FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
+ # auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
+ parsed =
+ parsed
+ |> ensure_sort_custom_field_loaded()
+
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
- csv_iodata = MembersCSV.export(members, parsed.member_fields, custom_fields_by_id)
+ columns = build_columns(conn, parsed, custom_fields_by_id)
+ csv_iodata = MembersCSV.export(members, columns)
filename = "members-#{Date.utc_today()}.csv"
send_download(
@@ -124,6 +146,26 @@ defmodule MvWeb.MemberExportController do
end
end
+ defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
+ case extract_sort_custom_field_id(sort_field) do
+ nil ->
+ parsed
+
+ id ->
+ %{parsed | custom_field_ids: Enum.uniq([id | ids])}
+ end
+ end
+
+ defp extract_sort_custom_field_id(field) when is_binary(field) do
+ if String.starts_with?(field, @custom_field_prefix) do
+ String.trim_leading(field, @custom_field_prefix)
+ else
+ nil
+ end
+ end
+
+ defp extract_sort_custom_field_id(_), do: nil
+
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
defp load_custom_fields_by_id(custom_field_ids, actor) do
@@ -148,17 +190,13 @@ defmodule MvWeb.MemberExportController do
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
- find_and_add_custom_field(acc, id, custom_fields)
+ case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
+ nil -> acc
+ cf -> Map.put(acc, id, cf)
+ end
end)
end
- defp find_and_add_custom_field(acc, id, custom_fields) do
- case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
- nil -> acc
- cf -> Map.put(acc, id, cf)
- end
- end
-
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1)
@@ -170,20 +208,20 @@ defmodule MvWeb.MemberExportController do
query =
if parsed.selected_ids != [] do
+ # selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
query
|> apply_search_export(parsed.query)
- |> then(fn q ->
- {q, _sort_after_load} = maybe_sort_export(q, parsed.sort_field, parsed.sort_order)
- q
- end)
end
+ # FIX: Sortierung IMMER anwenden (auch bei selected_ids)
+ {query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)
+
case Ash.read(query, actor: actor) do
{:ok, members} ->
members =
- if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
+ if sort_after_load do
sort_members_by_custom_field_export(
members,
parsed.sort_field,
@@ -191,7 +229,6 @@ defmodule MvWeb.MemberExportController do
Map.values(custom_fields_by_id)
)
else
- # selected_ids != []: no sort. selected_ids == [] and DB sort: already in query.
members
end
@@ -228,25 +265,29 @@ defmodule MvWeb.MemberExportController do
defp maybe_sort_export(query, _field, nil), do: {query, false}
defp maybe_sort_export(query, field, order) when is_binary(field) do
- if custom_field_sort?(field) do
- {query, true}
- else
- field_atom = String.to_existing_atom(field)
+ cond do
+ custom_field_sort?(field) ->
+ # Custom field sort → in-memory nach dem Read (wie Tabelle)
+ {query, true}
- if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
- {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
- else
- {query, false}
- end
+ true ->
+ field_atom = String.to_existing_atom(field)
+
+ if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
+ {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
+ else
+ {query, false}
+ end
end
rescue
ArgumentError -> {query, false}
end
- defp sort_after_load?(field) when is_binary(field),
- do: String.starts_with?(field, @custom_field_prefix)
+ defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
- defp sort_after_load?(_), do: false
+ # ------------------------------------------------------------------
+ # Custom field sorting (match member table behavior)
+ # ------------------------------------------------------------------
defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields)
when members == [],
@@ -254,26 +295,60 @@ defmodule MvWeb.MemberExportController do
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
when is_binary(field) do
+ order = order || "asc"
id_str = String.trim_leading(field, @custom_field_prefix)
- custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
- if is_nil(custom_field), do: members
- extract_sort_val = fn member ->
- cfv = find_cfv(member, custom_field)
- if cfv, do: extract_sort_value(cfv.value, custom_field.value_type), else: nil
- end
+ custom_field =
+ Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
- sorted =
+ if is_nil(custom_field) do
members
- |> Enum.sort_by(extract_sort_val, fn
- nil, _ -> false
- _, nil -> true
- a, b -> if order == "desc", do: a >= b, else: a <= b
- end)
+ else
+ # Match table:
+ # 1) values first, empty last
+ # 2) sort only values
+ # 3) for desc, reverse only the values-part
+ {with_values, without_values} =
+ Enum.split_with(members, fn member ->
+ has_non_empty_custom_field_value?(member, custom_field)
+ end)
- if order == "desc", do: Enum.reverse(sorted), else: sorted
+ sorted_with_values =
+ Enum.sort_by(with_values, fn member ->
+ member
+ |> find_cfv(custom_field)
+ |> case do
+ nil -> nil
+ cfv -> extract_sort_value(cfv.value, custom_field.value_type)
+ end
+ end)
+
+ sorted_with_values =
+ if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
+
+ sorted_with_values ++ without_values
+ end
end
+ defp has_non_empty_custom_field_value?(member, custom_field) do
+ case find_cfv(member, custom_field) do
+ nil ->
+ false
+
+ cfv ->
+ extracted = extract_sort_value(cfv.value, custom_field.value_type)
+ not empty_value?(extracted, custom_field.value_type)
+ end
+ end
+
+ defp empty_value?(nil, _type), do: true
+
+ defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do
+ String.trim(value) == ""
+ end
+
+ defp empty_value?(_value, _type), do: false
+
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
@@ -283,15 +358,76 @@ defmodule MvWeb.MemberExportController do
end)
end
+ defp build_columns(conn, parsed, custom_fields_by_id) do
+ member_cols =
+ Enum.map(parsed.member_fields, fn field ->
+ %{
+ header: member_field_header(conn, field),
+ kind: :member_field,
+ key: field
+ }
+ end)
+
+ computed_cols =
+ Enum.map(parsed.computed_fields, fn key ->
+ %{
+ header: computed_field_header(conn, key),
+ kind: :computed,
+ key: key
+ }
+ end)
+
+ custom_cols =
+ parsed.custom_field_ids
+ |> Enum.map(fn id ->
+ cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
+
+ if cf do
+ %{
+ header: custom_field_header(conn, cf),
+ kind: :custom_field,
+ key: to_string(id),
+ custom_field: cf
+ }
+ else
+ nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+
+ member_cols ++ computed_cols ++ custom_cols
+ end
+
+ # --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
+ defp member_field_header(_conn, field) when is_binary(field) do
+ # TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
+ humanize_field(field)
+ end
+
+ defp computed_field_header(_conn, key) when is_binary(key) do
+ # TODO: display-name helper für computed fields verwenden
+ humanize_field(key)
+ end
+
+ defp custom_field_header(_conn, cf) do
+ # Custom fields: meist ist cf.name bereits der Display Name
+ cf.name
+ end
+
+ defp humanize_field(str) do
+ str
+ |> String.replace("_", " ")
+ |> String.capitalize()
+ end
+
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),
do: extract_sort_value(value, type)
+ defp extract_sort_value(nil, _), do: nil
defp extract_sort_value(value, :string) when is_binary(value), do: value
defp extract_sort_value(value, :integer) when is_integer(value), do: value
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
defp extract_sort_value(%Date{} = d, :date), do: d
defp extract_sort_value(value, :email) when is_binary(value), do: value
defp extract_sort_value(value, _), do: to_string(value)
-
- defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
end
diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex
index 426daed..0c9492b 100644
--- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex
+++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex
@@ -41,24 +41,29 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
# RENDER
# ---------------------------------------------------------------------------
+ # Export-only alias; must not appear in dropdown (canonical UI key is membership_fee_status).
+ @payment_status_value "payment_status"
+
@impl true
def render(assigns) do
all_fields = assigns.all_fields || []
custom_fields = assigns.custom_fields || []
all_items =
- Enum.map(extract_member_field_keys(all_fields), fn field ->
- %{
- value: field_to_string(field),
- label: format_field_label(field)
- }
- end) ++
- Enum.map(extract_custom_field_keys(all_fields), fn field ->
- %{
- value: field,
- label: format_custom_field_label(field, custom_fields)
- }
- end)
+ (Enum.map(extract_member_field_keys(all_fields), fn field ->
+ %{
+ value: field_to_string(field),
+ label: format_field_label(field)
+ }
+ end) ++
+ Enum.map(extract_custom_field_keys(all_fields), fn field ->
+ %{
+ value: field,
+ label: format_custom_field_label(field, custom_fields)
+ }
+ end))
+ |> Enum.reject(fn item -> item.value == @payment_status_value end)
+ |> Enum.uniq_by(fn item -> item.value end)
assigns = assign(assigns, :all_items, all_items)
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index acafcf3..a18ef41 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -39,10 +39,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
- # Prefix used in sort field names for custom fields (e.g., "custom_field_")
@custom_field_prefix Mv.Constants.custom_field_prefix()
-
- # Prefix used for boolean custom field filter URL parameters (e.g., "bf_")
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
# Maximum number of boolean custom field filters allowed per request (DoS protection)
@@ -99,10 +96,12 @@ defmodule MvWeb.MemberLive.Index do
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)
- # Get all available fields (for dropdown - includes ALL custom fields)
- all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
+ # FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice)
+ all_available_fields =
+ all_custom_fields
+ |> FieldVisibility.get_all_available_fields()
+ |> dedupe_available_fields()
- # Merge session selection with global settings for initial state (use all_custom_fields)
initial_selection =
FieldVisibility.merge_with_global_settings(
session_selection,
@@ -129,11 +128,18 @@ defmodule MvWeb.MemberLive.Index do
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
+ |> assign(
+ :member_fields_visible_db,
+ FieldVisibility.get_visible_member_fields_db(initial_selection)
+ )
+ |> assign(
+ :member_fields_visible_computed,
+ FieldVisibility.get_visible_member_fields_computed(initial_selection)
+ )
|> assign(:show_current_cycle, false)
|> assign(:membership_fee_status_filter, nil)
|> assign_export_payload()
- # We call handle params to use the query from the URL
{:ok, socket}
end
@@ -230,7 +236,6 @@ defmodule MvWeb.MemberLive.Index do
|> load_members()
|> update_selection_assigns()
- # Update URL to reflect cycle view change
query_params =
build_query_params(
socket.assigns.query,
@@ -300,9 +305,7 @@ defmodule MvWeb.MemberLive.Index do
Enum.join(error_messages, ", ")
end
- defp format_error(error) do
- inspect(error)
- end
+ defp format_error(error), do: inspect(error)
# -----------------------------------------------------------------
# Handle Infos from Child Components
@@ -328,14 +331,29 @@ defmodule MvWeb.MemberLive.Index do
end
{new_field, new_order} = determine_new_sort(field, socket)
-
old_field = socket.assigns.sort_field
- socket
- |> assign(:sort_field, new_field)
- |> assign(:sort_order, new_order)
- |> update_sort_components(old_field, new_field, new_order)
- |> push_sort_url(new_field, new_order)
+ socket =
+ socket
+ |> assign(:sort_field, new_field)
+ |> assign(:sort_order, new_order)
+ |> update_sort_components(old_field, new_field, new_order)
+ |> load_members()
+ |> update_selection_assigns()
+
+ # URL sync
+ query_params =
+ build_query_params(
+ socket.assigns.query,
+ export_sort_field(socket.assigns.sort_field),
+ export_sort_order(socket.assigns.sort_order),
+ socket.assigns.cycle_status_filter,
+ socket.assigns.show_current_cycle,
+ socket.assigns.boolean_custom_field_filters
+ )
+ |> maybe_add_field_selection(socket.assigns[:user_field_selection])
+
+ {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end
@impl true
@@ -346,29 +364,19 @@ defmodule MvWeb.MemberLive.Index do
|> load_members()
|> update_selection_assigns()
- existing_field_query = socket.assigns.sort_field
- existing_sort_query = socket.assigns.sort_order
-
- # Build the URL with queries
query_params =
build_query_params(
q,
- existing_field_query,
- existing_sort_query,
+ socket.assigns.sort_field,
+ socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
- # Set the new path with params
new_path = ~p"/members?#{query_params}"
- # Push the new URL
- {:noreply,
- push_patch(socket,
- to: new_path,
- replace: true
- )}
+ {:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
@@ -379,7 +387,6 @@ defmodule MvWeb.MemberLive.Index do
|> load_members()
|> update_selection_assigns()
- # Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
@@ -391,23 +398,15 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
-
- {:noreply,
- push_patch(socket,
- to: new_path,
- replace: true
- )}
+ {:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
- # Update boolean filters map
updated_filters =
if filter_value == nil do
- # Remove filter if nil (All option selected)
Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
else
- # Add or update filter
Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
end
@@ -417,7 +416,6 @@ defmodule MvWeb.MemberLive.Index do
|> load_members()
|> update_selection_assigns()
- # Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
@@ -429,18 +427,11 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
-
- {:noreply,
- push_patch(socket,
- to: new_path,
- replace: true
- )}
+ {:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
- # Reset all filters at once (performance optimization)
- # This avoids N×2 load_members() calls when resetting multiple filters
socket =
socket
|> assign(:cycle_status_filter, cycle_status_filter)
@@ -448,7 +439,6 @@ defmodule MvWeb.MemberLive.Index do
|> load_members()
|> update_selection_assigns()
- # Build the URL with all params including reset filters
query_params =
build_query_params(
socket.assigns.query,
@@ -460,23 +450,14 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
-
- {:noreply,
- push_patch(socket,
- to: new_path,
- replace: true
- )}
+ {:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
- # Update user field selection
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
-
- # Save to session (cookie will be saved on next page load via handle_params)
socket = update_session_field_selection(socket, new_selection)
- # Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
@@ -484,14 +465,24 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.all_custom_fields
)
- # Get visible fields
- visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
+ visible_member_fields =
+ final_selection
+ |> FieldVisibility.get_visible_member_fields()
+ |> Enum.uniq()
+
+ visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
+
+ visible_member_fields_computed =
+ FieldVisibility.get_visible_member_fields_computed(final_selection)
+
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
+ |> assign(:member_fields_visible_db, visible_member_fields_db)
+ |> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
@@ -503,10 +494,8 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_info({:fields_selected, selection}, socket) do
- # Save to session
socket = update_session_field_selection(socket, selection)
- # Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
selection,
@@ -514,14 +503,24 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.all_custom_fields
)
- # Get visible fields
- visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
+ visible_member_fields =
+ final_selection
+ |> FieldVisibility.get_visible_member_fields()
+ |> Enum.uniq()
+
+ visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
+
+ visible_member_fields_computed =
+ FieldVisibility.get_visible_member_fields_computed(final_selection)
+
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
+ |> assign(:member_fields_visible_db, visible_member_fields_db)
+ |> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
@@ -534,22 +533,12 @@ defmodule MvWeb.MemberLive.Index do
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
- @doc """
- Handles URL parameter changes.
- Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
- then loads members accordingly. This enables bookmarkable URLs and
- browser back/forward navigation.
- """
@impl true
def handle_params(params, _url, socket) do
- # Build signature BEFORE updates to detect if anything actually changed
prev_sig = build_signature(socket)
-
- # Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
- # Merge with session selection (URL has priority)
merged_selection =
FieldSelection.merge_sources(
url_selection,
@@ -557,7 +546,6 @@ defmodule MvWeb.MemberLive.Index do
%{}
)
- # Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
merged_selection,
@@ -565,11 +553,18 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.all_custom_fields
)
- # Get visible fields
- visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
+ visible_member_fields =
+ final_selection
+ |> FieldVisibility.get_visible_member_fields()
+ |> Enum.uniq()
+
+ visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
+
+ visible_member_fields_computed =
+ FieldVisibility.get_visible_member_fields_computed(final_selection)
+
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
- # Apply all updates
socket =
socket
|> maybe_update_search(params)
@@ -580,21 +575,18 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
+ |> assign(:member_fields_visible_db, visible_member_fields_db)
+ |> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
- # Build signature AFTER updates
next_sig = build_signature(socket)
- # Only load members if signature changed (optimization: avoid duplicate loads)
- # OR if members haven't been loaded yet (first handle_params call after mount)
socket =
if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
- # Nothing changed AND members already loaded, skip expensive load_members() call
socket
|> prepare_dynamic_cols()
|> update_selection_assigns()
else
- # Signature changed OR members not loaded yet, reload members
socket
|> load_members()
|> prepare_dynamic_cols()
@@ -604,20 +596,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, socket}
end
- # Builds a signature tuple representing all filter/sort parameters that affect member loading.
- #
- # This signature is used to detect if member data needs to be reloaded when handle_params
- # is called. If the signature hasn't changed, we can skip the expensive load_members() call.
- #
- # Returns a tuple containing all relevant parameters:
- # - query: Search query string
- # - sort_field: Field to sort by
- # - sort_order: Sort direction (:asc or :desc)
- # - cycle_status_filter: Payment filter (:paid, :unpaid, or nil)
- # - show_current_cycle: Whether to show current cycle
- # - boolean_custom_field_filters: Map of active boolean filters
- # - user_field_selection: Map of user's field visibility selections
- # - visible_custom_field_ids: List of visible custom field IDs (affects which custom fields are loaded)
defp build_signature(socket) do
{
socket.assigns.query,
@@ -631,32 +609,22 @@ defmodule MvWeb.MemberLive.Index do
}
end
- # Prepares dynamic column definitions for custom fields that should be shown in the overview.
- #
- # Creates a list of column definitions, each containing:
- # - `:custom_field` - The CustomField resource
- # - `:render` - A function that formats the custom field value for a given member
- #
- # Only includes custom fields that are visible according to user field selection.
- #
- # Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
+ visible_set = MapSet.new(visible_custom_field_ids)
- # Use all_custom_fields to allow users to enable globally hidden custom fields
dynamic_cols =
socket.assigns.all_custom_fields
- |> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
+ |> Enum.filter(fn custom_field ->
+ MapSet.member?(visible_set, to_string(custom_field.id))
+ end)
|> Enum.map(fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
case get_custom_field_value(member, custom_field) do
- nil ->
- ""
-
- cfv ->
- Formatter.format_custom_field_value(cfv.value, custom_field)
+ nil -> ""
+ cfv -> Formatter.format_custom_field_value(cfv.value, custom_field)
end
end
}
@@ -666,10 +634,9 @@ defmodule MvWeb.MemberLive.Index do
end
# -------------------------------------------------------------
- # FUNCTIONS
+ # Sorting
# -------------------------------------------------------------
- # Determines new sort field and order based on current state
defp determine_new_sort(field, socket) do
if socket.assigns.sort_field == field do
{field, toggle_order(socket.assigns.sort_order)}
@@ -678,19 +645,16 @@ defmodule MvWeb.MemberLive.Index do
end
end
- # Updates both the active and old SortHeader components
defp update_sort_components(socket, old_field, new_field, new_order) do
active_id = to_sort_id(new_field)
old_id = to_sort_id(old_field)
- # Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
- # Reset the current SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
@@ -700,8 +664,6 @@ defmodule MvWeb.MemberLive.Index do
socket
end
- # Converts a field (atom or string) to a sort component ID atom
- # Handles both existing atoms and strings that need to be converted
defp to_sort_id(field) when is_binary(field) do
try do
String.to_existing_atom("sort_#{field}")
@@ -710,11 +672,8 @@ defmodule MvWeb.MemberLive.Index do
end
end
- defp to_sort_id(field) when is_atom(field) do
- :"sort_#{field}"
- end
+ defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
- # Builds sort URL and pushes navigation patch
defp push_sort_url(socket, field, order) do
field_str =
if is_atom(field) do
@@ -735,14 +694,9 @@ defmodule MvWeb.MemberLive.Index do
new_path = ~p"/members?#{query_params}"
- {:noreply,
- push_patch(socket,
- to: new_path,
- replace: true
- )}
+ {:noreply, push_patch(socket, to: new_path, replace: true)}
end
- # Adds field selection to query params if present
defp maybe_add_field_selection(params, nil), do: params
defp maybe_add_field_selection(params, selection) when is_map(selection) do
@@ -752,7 +706,6 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_add_field_selection(params, _), do: params
- # Pushes URL with updated field selection
defp push_field_selection_url(socket) do
query_params =
build_query_params(
@@ -766,20 +719,13 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
new_path = ~p"/members?#{query_params}"
-
push_patch(socket, to: new_path, replace: true)
end
- # Updates session field selection (stored in socket for now, actual session update via controller)
defp update_session_field_selection(socket, selection) do
- # Store in socket for now - actual session persistence would require a controller
- # This is a placeholder for future session persistence
assign(socket, :user_field_selection, selection)
end
- # Builds URL query parameters map including all filter/sort state.
- # Converts cycle_status_filter atom to string for URL.
- # Adds boolean custom field filters as bf_=true|false.
defp build_query_params(
query,
sort_field,
@@ -808,7 +754,6 @@ defmodule MvWeb.MemberLive.Index do
"sort_order" => order_str
}
- # Only add cycle_status_filter to URL if it's set
base_params =
case cycle_status_filter do
nil -> base_params
@@ -816,7 +761,6 @@ defmodule MvWeb.MemberLive.Index do
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
end
- # Add show_current_cycle if true
base_params =
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
@@ -824,7 +768,6 @@ defmodule MvWeb.MemberLive.Index do
base_params
end
- # Add boolean custom field filters
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
param_value = if filter_value == true, do: "true", else: "false"
@@ -832,25 +775,10 @@ defmodule MvWeb.MemberLive.Index do
end)
end
- # Loads members from the database with custom field values and applies search/sort/payment filters.
- #
- # Process:
- # 1. Builds base query with selected fields
- # 2. Loads custom field values for visible custom fields (filtered at database level)
- # 3. Applies search filter if provided
- # 4. Applies payment status filter if set
- # 5. Applies sorting (database-level for regular fields, in-memory for custom fields)
- #
- # Performance Considerations:
- # - Database-level filtering: Custom field values are filtered directly in the database
- # using Ash relationship filters, reducing memory usage and improving performance.
- # - In-memory sorting: Custom field sorting is done in memory after loading.
- # This is suitable for small to medium datasets (<1000 members).
- # For larger datasets, consider implementing database-level sorting or pagination.
- # - No pagination: All matching members are loaded at once. For large result sets,
- # consider implementing pagination (see Issue #165).
- #
- # Returns the socket with `:members` assigned.
+ # -------------------------------------------------------------
+ # Loading members
+ # -------------------------------------------------------------
+
defp load_members(socket) do
search_query = socket.assigns.query
@@ -859,12 +787,8 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
- # Load custom field values for visible custom fields AND active boolean filters
- # This ensures boolean filters work even when the custom field is not visible in overview
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
- # Get IDs of active boolean filters (whitelisted against boolean_custom_fields)
- # Convert boolean_custom_fields list to map for efficient lookup (consistent with maybe_update_boolean_filters)
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
@@ -873,40 +797,36 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
- # Validate UUID format and check against whitelist
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
- # Union of visible IDs and active filter IDs
ids_to_load =
(visible_custom_field_ids ++ active_boolean_filter_ids)
|> Enum.uniq()
query = load_custom_field_values(query, ids_to_load)
- # Load membership fee cycles for status display
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
- # Apply the search filter first
query = apply_search_filter(query, search_query)
- # Apply sorting based on current socket state
- # For custom fields, we sort after loading
+ # Use ALL custom fields for sorting (not just show_in_overview subset)
+ custom_fields_for_sort = socket.assigns.all_custom_fields
+
{query, sort_after_load} =
maybe_sort(
query,
socket.assigns.sort_field,
socket.assigns.sort_order,
- socket.assigns.custom_fields_visible
+ custom_fields_for_sort
)
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
- time_milliseconds = time_microseconds / 1000
- Logger.info("Ash.read! in load_members/1 took #{time_milliseconds} ms")
+ Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
@@ -927,14 +847,15 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.all_custom_fields
)
- # Sort in memory if needed (for custom fields)
+ # Sort in memory if needed (custom fields only; computed fields are blocked)
members =
- if sort_after_load do
+ if sort_after_load and
+ socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
sort_members_in_memory(
members,
socket.assigns.sort_field,
socket.assigns.sort_order,
- socket.assigns.custom_fields_visible
+ custom_fields_for_sort
)
else
members
@@ -943,22 +864,9 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :members, members)
end
- # Load custom field values for the given custom field IDs
- #
- # Filters custom field values directly in the database using Ash relationship filters.
- # This is more efficient than loading all values and filtering in memory.
- #
- # Performance: Database-level filtering reduces:
- # - Memory usage (only visible custom field values are loaded)
- # - Network transfer (less data from database to application)
- # - Processing time (no need to iterate through all members and filter)
- defp load_custom_field_values(query, []) do
- query
- end
+ defp load_custom_field_values(query, []), do: query
defp load_custom_field_values(query, custom_field_ids) do
- # Filter custom field values at the database level using Ash relationship query
- # This ensures only visible custom field values are loaded
custom_field_values_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
@@ -972,24 +880,15 @@ defmodule MvWeb.MemberLive.Index do
# Helper Functions
# -------------------------------------------------------------
- # Function to apply search query
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
- |> Mv.Membership.Member.fuzzy_search(%{
- query: search_query
- })
+ |> Mv.Membership.Member.fuzzy_search(%{query: search_query})
else
query
end
end
- # Applies cycle status filter to members list.
- #
- # Filter values:
- # - nil: No filter, return all members
- # - :paid: Only members with paid status in the selected cycle (last or current)
- # - :unpaid: Only members with unpaid status in the selected cycle (last or current)
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current)
@@ -997,50 +896,78 @@ defmodule MvWeb.MemberLive.Index do
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
end
- # Functions to toggle sorting order
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp toggle_order(nil), do: :asc
- # Function to sort the column if needed
- # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory
- defp maybe_sort(query, nil, _, _), do: {query, false}
+ # Function to sort the column if needed.
+ # Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash.
+ # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory.
+ defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false}
+ defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
- defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do
- if custom_field_sort?(field) do
- # Custom fields need to be sorted in memory after loading
- {query, true}
- else
- # Only sort by atom fields (regular member fields) in database
- if is_atom(field) do
- {Ash.Query.sort(query, [{field, order}]), false}
- else
+ defp maybe_sort(query, field, order, _custom_fields) do
+ computed_atoms = FieldVisibility.computed_member_fields()
+ computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
+
+ cond do
+ # Block computed fields (atom and string variants)
+ (is_atom(field) and field in computed_atoms) or
+ (is_binary(field) and field in computed_strings) ->
+ {query, false}
+
+ # Custom field sort -> after load
+ custom_field_sort?(field) ->
+ {query, true}
+
+ # DB field sort (atom)
+ is_atom(field) ->
+ {Ash.Query.sort(query, [{field, order}]), false}
+
+ # DB field sort (string) -> convert only if allowed
+ is_binary(field) ->
+ case safe_member_field_atom_only(field) do
+ nil -> {query, false}
+ atom -> {Ash.Query.sort(query, [{atom, order}]), false}
+ end
+
+ true ->
{query, false}
- end
end
end
- defp maybe_sort(query, _, _, _), do: {query, false}
-
- # Validate that a field is sortable
- # Uses member fields from constants, but excludes fields that don't make sense to sort
- # (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
defp valid_sort_field?(field) when is_atom(field) do
- # All member fields are sortable, but we exclude some that don't make sense
- # :id is not in member_fields, but we don't want to sort by it anyway
- non_sortable_fields = [:notes]
- valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
-
- field in valid_fields or custom_field_sort?(field)
+ if field in FieldVisibility.computed_member_fields(),
+ do: false,
+ else: valid_sort_field_db_or_custom?(field)
end
defp valid_sort_field?(field) when is_binary(field) do
- custom_field_sort?(field)
+ if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
+ false
+ else
+ valid_sort_field_db_or_custom?(field)
+ end
end
defp valid_sort_field?(_), do: false
- # Check if field is a custom field sort field (format: custom_field_)
+ 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
+ field in valid_fields or custom_field_sort?(field)
+ end
+
+ defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
+ custom_field_sort?(field) or
+ ((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom))
+ end
+
+ defp safe_member_field_atom_only(str) do
+ allowed = MapSet.new(Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1))
+ if MapSet.member?(allowed, str), do: String.to_existing_atom(str), else: nil
+ end
+
defp custom_field_sort?(field) when is_atom(field) do
field_str = Atom.to_string(field)
String.starts_with?(field_str, @custom_field_prefix)
@@ -1052,18 +979,8 @@ defmodule MvWeb.MemberLive.Index do
defp custom_field_sort?(_), do: false
- # Extracts the custom field ID from a sort field name.
- #
- # Sort fields for custom fields use the format: "custom_field_"
- # This function extracts the ID part.
- #
- # Examples:
- # extract_custom_field_id("custom_field_123") -> "123"
- # extract_custom_field_id(:custom_field_123) -> "123"
- # extract_custom_field_id("first_name") -> nil
defp extract_custom_field_id(field) when is_atom(field) do
- field_str = Atom.to_string(field)
- extract_custom_field_id(field_str)
+ field |> Atom.to_string() |> extract_custom_field_id()
end
defp extract_custom_field_id(field) when is_binary(field) do
@@ -1075,8 +992,6 @@ defmodule MvWeb.MemberLive.Index do
defp extract_custom_field_id(_), do: nil
- # Extracts custom field IDs from visible custom field strings
- # Format: "custom_field_" ->
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, @custom_field_prefix) do
@@ -1087,79 +1002,40 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1 != nil))
end
- # Sorts members in memory by a custom field value.
- #
- # Process:
- # 1. Extracts custom field ID from sort field name
- # 2. Finds the corresponding CustomField resource
- # 3. Splits members into those with values and those without
- # 4. Sorts members with values by the extracted value
- # 5. Combines: sorted values first, then NULL/empty values at the end
- #
- # Performance Note:
- # This function sorts in memory, which is suitable for small to medium datasets (<1000 members).
- # For larger datasets, consider implementing database-level sorting or pagination.
- #
- # Parameters:
- # - `members` - List of Member resources to sort
- # - `field` - Sort field name (format: "custom_field_" or atom)
- # - `order` - Sort order (`:asc` or `:desc`)
- # - `custom_fields` - List of visible CustomField resources
- #
- # Returns the sorted list of members.
defp sort_members_in_memory(members, field, order, custom_fields) do
custom_field_id_str = extract_custom_field_id(field)
case custom_field_id_str do
- nil ->
- members
-
- id_str ->
- sort_members_by_custom_field(members, id_str, order, custom_fields)
+ nil -> members
+ id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end
- # Sorts members by a specific custom field ID
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
custom_field = find_custom_field_by_id(custom_fields, id_str)
case custom_field do
- nil ->
- members
-
- cf ->
- sort_members_with_custom_field(members, cf, order)
+ nil -> members
+ cf -> sort_members_with_custom_field(members, cf, order)
end
end
- # Finds a custom field by matching its ID string
defp find_custom_field_by_id(custom_fields, id_str) do
- Enum.find(custom_fields, fn cf ->
- to_string(cf.id) == id_str
- end)
+ Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
end
- # Sorts members that have a specific custom field
defp sort_members_with_custom_field(members, custom_field, order) do
- # Split members into those with values and those without (NULL/empty)
{members_with_values, members_without_values} =
split_members_by_value_presence(members, custom_field)
- # Sort members with values
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
-
- # Combine: sorted values first, then NULL/empty values at the end
sorted_with_values ++ members_without_values
end
- # Splits members into those with values and those without
defp split_members_by_value_presence(members, custom_field) do
- Enum.split_with(members, fn member ->
- has_non_empty_value?(member, custom_field)
- end)
+ Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end)
end
- # Checks if a member has a non-empty value for the custom field
defp has_non_empty_value?(member, custom_field) do
case get_custom_field_value(member, custom_field) do
nil ->
@@ -1171,7 +1047,6 @@ defmodule MvWeb.MemberLive.Index do
end
end
- # Sorts members that have values for the custom field
defp sort_members_with_values(members_with_values, custom_field, order) do
sorted =
Enum.sort_by(members_with_values, fn member ->
@@ -1180,24 +1055,11 @@ defmodule MvWeb.MemberLive.Index do
normalize_sort_value(extracted, order)
end)
- # For DESC, reverse only the members with values
- if order == :desc do
- Enum.reverse(sorted)
- else
- sorted
- end
+ if order == :desc, do: Enum.reverse(sorted), else: sorted
end
- # Extracts a sortable value from a custom field value based on its type.
- #
- # Handles different value formats:
- # - `%Ash.Union{}` - Extracts value and type from union
- # - Direct values - Returns as-is for primitive types
- #
- # Returns the extracted value suitable for sorting.
- defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do
- extract_sort_value(value, type)
- end
+ defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type),
+ do: extract_sort_value(value, type)
defp extract_sort_value(value, :string) when is_binary(value), do: value
defp extract_sort_value(value, :integer) when is_integer(value), do: value
@@ -1206,25 +1068,12 @@ defmodule MvWeb.MemberLive.Index do
defp extract_sort_value(value, :email) when is_binary(value), do: value
defp extract_sort_value(value, _type), do: to_string(value)
- # Check if a value is considered empty (NULL or empty string)
- defp empty_value?(value, :string) when is_binary(value) do
- String.trim(value) == ""
- end
-
- defp empty_value?(value, :email) when is_binary(value) do
- String.trim(value) == ""
- end
-
+ defp empty_value?(value, :string) when is_binary(value), do: String.trim(value) == ""
+ defp empty_value?(value, :email) when is_binary(value), do: String.trim(value) == ""
defp empty_value?(_value, _type), do: false
- # Normalize sort value for DESC order
- # For DESC, we sort ascending first, then reverse the list
- # This function is kept for consistency but doesn't need to invert values
defp normalize_sort_value(value, _order), do: value
- # Updates sort field and order from URL parameters if present.
- #
- # Validates the sort field and order, falling back to defaults if invalid.
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
@@ -1236,50 +1085,38 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_sort(socket, _), do: socket
- # Determine sort field from URL parameter, validating against allowed fields
defp determine_field(default, ""), do: default
defp determine_field(default, nil), do: default
- # Determines the valid sort field from a URL parameter.
- #
- # Validates the field against allowed sort fields (regular member fields or custom fields).
- # Falls back to default if the field is invalid.
- #
- # Parameters:
- # - `default` - Default field to use if validation fails
- # - `sf` - Sort field from URL (can be atom, string, nil, or empty string)
- #
- # Returns a valid sort field (atom or string for custom fields).
defp determine_field(default, sf) when is_binary(sf) do
- # Check if it's a custom field sort (starts with "custom_field_")
- if custom_field_sort?(sf) do
- if valid_sort_field?(sf), do: sf, else: default
- else
- # Try to convert to atom for regular fields
- try do
- atom = String.to_existing_atom(sf)
- if valid_sort_field?(atom), do: atom, else: default
- rescue
- ArgumentError -> default
- end
- end
+ computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
+
+ if sf in computed_strings,
+ do: default,
+ else: determine_field_after_computed_check(default, sf)
end
defp determine_field(default, sf) when is_atom(sf) do
- if valid_sort_field?(sf), do: sf, else: default
+ if sf in FieldVisibility.computed_member_fields(),
+ do: default,
+ else: determine_field_after_computed_check(default, sf)
end
defp determine_field(default, _), do: default
- # Determines the valid sort order from a URL parameter.
- #
- # Validates that the order is either "asc" or "desc", falling back to default if invalid.
- #
- # Parameters:
- # - `default` - Default order to use if validation fails
- # - `so` - Sort order from URL (string, atom, nil, or empty string)
- #
- # Returns `:asc` or `:desc`.
+ defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
+ if custom_field_sort?(sf) do
+ if valid_sort_field?(sf), do: sf, else: default
+ else
+ atom = safe_member_field_atom_only(sf)
+ if atom != nil and valid_sort_field?(atom), do: atom, else: default
+ end
+ end
+
+ defp determine_field_after_computed_check(default, sf) when is_atom(sf) do
+ if valid_sort_field?(sf), do: sf, else: default
+ end
+
defp determine_order(default, so) do
case so do
"" -> default
@@ -1289,59 +1126,29 @@ defmodule MvWeb.MemberLive.Index do
end
end
- # Function to update search parameters
- defp maybe_update_search(socket, %{"query" => query}) when query != "" do
- assign(socket, :query, query)
- end
+ defp maybe_update_search(socket, %{"query" => query}) when query != "",
+ do: assign(socket, :query, query)
- defp maybe_update_search(socket, _params) do
- # Keep the previous search query if no new one is provided
- socket
- end
+ defp maybe_update_search(socket, _params), do: socket
- # Updates cycle status filter from URL parameters if present.
- #
- # Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
filter = determine_cycle_status_filter(filter_str)
assign(socket, :cycle_status_filter, filter)
end
- defp maybe_update_cycle_status_filter(socket, _params) do
- # Reset filter if not in URL params
- assign(socket, :cycle_status_filter, nil)
- end
+ defp maybe_update_cycle_status_filter(socket, _params),
+ do: assign(socket, :cycle_status_filter, nil)
- # Determines valid cycle status filter from URL parameter.
- #
- # SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid"
- # are accepted - all other input (including malicious strings) falls back to nil.
- # This ensures no raw user input is ever passed to filter functions.
defp determine_cycle_status_filter("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
- # Updates boolean custom field filters from URL parameters if present.
- #
- # Parses all URL parameters with prefix @boolean_filter_prefix and validates them:
- # - Extracts custom field ID from parameter name (explicitly removes prefix)
- # - Validates filter value using determine_boolean_filter/1
- # - Whitelisting: Only custom field IDs that exist and have value_type: :boolean
- # - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks
- # - Security: Validates UUID length (max @max_uuid_length characters)
- #
- # Returns socket with updated :boolean_custom_field_filters assign.
defp maybe_update_boolean_filters(socket, params) do
- # Get all boolean custom fields for whitelisting (keyed by ID as string for consistency)
boolean_custom_fields =
socket.assigns.all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
- # Parse all boolean filter parameters
- # Security: Use reduce_while to abort early after @max_boolean_filters to prevent DoS attacks
- # This protects CPU/Parsing costs, not just memory/state
- # We count processed parameters (not just valid filters) to protect against parsing DoS
prefix_length = String.length(@boolean_filter_prefix)
{filters, total_processed} =
@@ -1360,13 +1167,10 @@ defmodule MvWeb.MemberLive.Index do
acc
)
- # Increment counter for each processed parameter (DoS protection)
- # Note: We count processed params, not just valid filters, to protect parsing costs
{:cont, {new_acc, count + 1}}
end
end)
- # Log warning if we hit the limit
if total_processed >= @max_boolean_filters do
Logger.warning(
"Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
@@ -1376,63 +1180,27 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :boolean_custom_field_filters, filters)
end
- # Processes a single boolean filter parameter from URL params.
- #
- # Validates the parameter and adds it to the accumulator if valid.
- # Returns the accumulator unchanged if validation fails.
- defp process_boolean_filter_param(
- key,
- value_str,
- prefix_length,
- boolean_custom_fields,
- acc
- ) do
- # Extract custom field ID from parameter name (explicitly remove prefix)
- # This is more secure than String.replace_prefix which only removes first occurrence
+ defp process_boolean_filter_param(key, value_str, prefix_length, boolean_custom_fields, acc) do
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
- # Validate custom field ID length (UUIDs are max @max_uuid_length characters)
- # This provides an additional security layer beyond UUID format validation
if String.length(custom_field_id_str) > @max_uuid_length do
acc
else
- validate_and_add_boolean_filter(
- custom_field_id_str,
- value_str,
- boolean_custom_fields,
- acc
- )
+ validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc)
end
end
- # Validates UUID format and custom field existence, then adds filter if valid.
- defp validate_and_add_boolean_filter(
- custom_field_id_str,
- value_str,
- boolean_custom_fields,
- acc
- ) do
+ defp validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) do
case Ecto.UUID.cast(custom_field_id_str) do
{:ok, _custom_field_id} ->
- add_boolean_filter_if_valid(
- custom_field_id_str,
- value_str,
- boolean_custom_fields,
- acc
- )
+ add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc)
:error ->
acc
end
end
- # Adds boolean filter to accumulator if custom field exists and value is valid.
- defp add_boolean_filter_if_valid(
- custom_field_id_str,
- value_str,
- boolean_custom_fields,
- acc
- ) do
+ defp add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) do
if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
case determine_boolean_filter(value_str) do
nil -> acc
@@ -1443,45 +1211,19 @@ defmodule MvWeb.MemberLive.Index do
end
end
- # Determines valid boolean filter value from URL parameter.
- #
- # SECURITY: This function whitelists allowed filter values. Only "true" and "false"
- # are accepted - all other input (including malicious strings) falls back to nil.
- # This ensures no raw user input is ever passed to filter functions.
- #
- # Returns:
- # - `true` for "true" string
- # - `false` for "false" string
- # - `nil` for any other value
defp determine_boolean_filter("true"), do: true
defp determine_boolean_filter("false"), do: false
defp determine_boolean_filter(_), do: nil
- # Updates show_current_cycle from URL parameters if present.
- defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
- assign(socket, :show_current_cycle, true)
- end
+ defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}),
+ do: assign(socket, :show_current_cycle, true)
- defp maybe_update_show_current_cycle(socket, _params) do
- socket
- end
+ defp maybe_update_show_current_cycle(socket, _params), do: socket
# -------------------------------------------------------------
- # Helper Functions for Custom Field Values
+ # Custom Field Value Helpers
# -------------------------------------------------------------
- # Retrieves the custom field value for a specific member and custom field.
- #
- # Searches through the member's `custom_field_values` relationship to find
- # the value matching the given custom field.
- #
- # Returns:
- # - `%CustomFieldValue{}` if found
- # - `nil` if not found or if member has no custom field values
- #
- # Examples:
- # get_custom_field_value(member, custom_field) -> %CustomFieldValue{...}
- # get_custom_field_value(member, non_existent_field) -> nil
def get_custom_field_value(member, custom_field) do
case member.custom_field_values do
nil ->
@@ -1498,46 +1240,17 @@ defmodule MvWeb.MemberLive.Index do
end
end
- # Extracts the boolean value from a member's custom field value.
- #
- # Handles different value formats:
- # - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union
- # - Map format with `"type"` and `"value"` keys - Extracts from map
- # - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map
- #
- # Returns:
- # - `true` if the custom field value is boolean true
- # - `false` if the custom field value is boolean false
- # - `nil` if no custom field value exists, value is nil, or value is not boolean
- #
- # Examples:
- # get_boolean_custom_field_value(member, boolean_field) -> true
- # get_boolean_custom_field_value(member, non_existent_field) -> nil
def get_boolean_custom_field_value(member, custom_field) do
case get_custom_field_value(member, custom_field) do
- nil ->
- nil
-
- cfv ->
- extract_boolean_value(cfv.value)
+ nil -> nil
+ cfv -> extract_boolean_value(cfv.value)
end
end
- # Extracts boolean value from custom field value, handling different formats.
- #
- # Handles:
- # - `%Ash.Union{value: value, type: :boolean}` - Union struct format
- # - Map with `"type"` and `"value"` keys - JSONB map format
- # - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format
- # - Direct boolean value - Primitive boolean
- #
- # Returns `true`, `false`, or `nil`.
- defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do
- extract_boolean_value(value)
- end
+ defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}),
+ do: extract_boolean_value(value)
defp extract_boolean_value(value) when is_map(value) do
- # Handle map format from JSONB
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
@@ -1552,94 +1265,43 @@ defmodule MvWeb.MemberLive.Index do
defp extract_boolean_value(nil), do: nil
defp extract_boolean_value(_), do: nil
- # Applies boolean custom field filters to a list of members.
- #
- # Filters members based on boolean custom field values. Only members that match
- # ALL active filters (AND logic) are returned.
- #
- # Parameters:
- # - `members` - List of Member resources with loaded custom_field_values
- # - `filters` - Map of `%{custom_field_id_string => true | false}`
- # - `all_custom_fields` - List of all CustomField resources (for validation)
- #
- # Returns:
- # - Filtered list of members that match all active filters
- # - All members if filters map is empty
- # - Filters with non-existent custom field IDs are ignored
- #
- # Examples:
- # apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...]
- # apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members
def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
when map_size(filters) == 0 do
members
end
def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
- # Build a map of valid boolean custom field IDs (as strings) for quick lookup
valid_custom_field_ids =
all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> MapSet.new(fn cf -> to_string(cf.id) end)
- # Filter out invalid custom field IDs from filters
valid_filters =
Enum.filter(filters, fn {custom_field_id_str, _value} ->
MapSet.member?(valid_custom_field_ids, custom_field_id_str)
end)
|> Enum.into(%{})
- # If no valid filters remain, return all members
if map_size(valid_filters) == 0 do
members
else
- Enum.filter(members, fn member ->
- matches_all_filters?(member, valid_filters)
- end)
+ Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end)
end
end
- # Checks if a member matches all active boolean filters.
- #
- # A member matches a filter if:
- # - The filter value is `true` and the member's custom field value is `true`
- # - The filter value is `false` and the member's custom field value is `false`
- #
- # Members without a custom field value or with `nil` value do not match any filter.
- #
- # Returns `true` if all filters match, `false` otherwise.
defp matches_all_filters?(member, filters) do
Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
matches_filter?(member, custom_field_id_str, filter_value)
end)
end
- # Checks if a member matches a specific boolean filter.
- #
- # Finds the custom field value by ID and checks if the member's boolean value
- # matches the filter value.
- #
- # Returns:
- # - `true` if the member's boolean value matches the filter value
- # - `false` if no custom field value exists (member is filtered out)
- # - `false` if value is nil or values don't match
defp matches_filter?(member, custom_field_id_str, filter_value) do
case find_custom_field_value_by_id(member, custom_field_id_str) do
- nil ->
- false
-
- cfv ->
- boolean_value = extract_boolean_value(cfv.value)
- boolean_value == filter_value
+ nil -> false
+ cfv -> extract_boolean_value(cfv.value) == filter_value
end
end
- # Finds a custom field value by custom field ID string.
- #
- # Searches through the member's custom_field_values to find one matching
- # the given custom field ID.
- #
- # Returns the CustomFieldValue or nil.
defp find_custom_field_value_by_id(member, custom_field_id_str) do
case member.custom_field_values do
nil ->
@@ -1657,9 +1319,6 @@ defmodule MvWeb.MemberLive.Index do
end
end
- # Filters selected members with email addresses and formats them.
- # Returns a list of formatted email strings in the format "First Last ".
- # Used by both copy_emails and mailto links.
def format_selected_member_emails(members, selected_members) do
members
|> Enum.filter(fn member ->
@@ -1668,18 +1327,8 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.map(&format_member_email/1)
end
- @doc """
- Returns a JS command to toggle member selection when clicking the checkbox column.
+ def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id})
- Used as `col_click` handler to ensure clicking anywhere in the checkbox column
- toggles the checkbox instead of navigating to the member details.
- """
- def checkbox_column_click(member) do
- JS.push("select_member", value: %{id: member.id})
- end
-
- # Formats a member's email in the format "First Last "
- # Used for copy_emails feature and mailto links to create email-client-friendly format.
def format_member_email(member) do
first_name = member.first_name || ""
last_name = member.last_name || ""
@@ -1689,33 +1338,17 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1 != ""))
|> Enum.join(" ")
- if name == "" do
- member.email
- else
- "#{name} <#{member.email}>"
- end
+ if name == "", do: member.email, else: "#{name} <#{member.email}>"
end
- # Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date)
- # Updates selection-related assigns (selected_count, any_selected?, mailto_bcc)
- # to avoid recalculating Enum.any? and Enum.count multiple times in templates.
- #
- # Note: Mailto URLs have length limits that vary by email client.
- # For large selections, consider using export functionality instead.
- #
- # Handles case where members haven't been loaded yet (e.g., when signature didn't change in handle_params).
defp update_selection_assigns(socket) do
- # Handle case where members haven't been loaded yet (e.g., when signature didn't change)
members = socket.assigns[:members] || []
selected_members = socket.assigns.selected_members
- selected_count =
- Enum.count(members, &MapSet.member?(selected_members, &1.id))
-
- any_selected? =
- Enum.any?(members, &MapSet.member?(selected_members, &1.id))
+ selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
+ any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
mailto_bcc =
if any_selected? do
@@ -1733,27 +1366,52 @@ defmodule MvWeb.MemberLive.Index do
|> assign_export_payload()
end
- # Builds the export payload map and assigns :export_payload_json for the CSV export form.
- # Called when selection, visible fields, query, or sort change so the form always has current data.
defp assign_export_payload(socket) do
payload = build_export_payload(socket)
assign(socket, :export_payload_json, Jason.encode!(payload))
end
defp build_export_payload(socket) do
- member_fields_visible = socket.assigns[:member_fields_visible] || []
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
+ member_fields_db = socket.assigns[:member_fields_visible_db] || []
+ member_fields_computed = socket.assigns[:member_fields_visible_computed] || []
+
+ # Order DB member fields exactly like the table/constants
+ ordered_member_fields_db =
+ Mv.Constants.member_fields()
+ |> Enum.filter(&(&1 in member_fields_db))
+
+ # Order computed fields in canonical order
+ ordered_computed_fields =
+ FieldVisibility.computed_member_fields()
+ |> Enum.filter(&(&1 in member_fields_computed))
+
+ # Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
+ ordered_custom_field_ids =
+ socket.assigns.all_custom_fields
+ |> Enum.map(&to_string(&1.id))
+ |> Enum.filter(&(&1 in visible_custom_field_ids))
+
%{
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
- member_fields: Enum.map(member_fields_visible, &Atom.to_string/1),
- custom_field_ids: visible_custom_field_ids,
+ member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
+ computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
+ custom_field_ids: ordered_custom_field_ids,
query: socket.assigns[:query] || nil,
sort_field: export_sort_field(socket.assigns[:sort_field]),
- sort_order: export_sort_order(socket.assigns[:sort_order])
+ sort_order: export_sort_order(socket.assigns[:sort_order]),
+ show_current_cycle: socket.assigns[:show_current_cycle] || false,
+ cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]),
+ boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{}
}
end
+ defp export_cycle_status_filter(nil), do: nil
+ defp export_cycle_status_filter(:paid), do: "paid"
+ defp export_cycle_status_filter(:unpaid), do: "unpaid"
+ defp export_cycle_status_filter(_), do: nil
+
defp export_sort_field(nil), do: nil
defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f)
defp export_sort_field(f) when is_binary(f), do: f
@@ -1762,4 +1420,25 @@ defmodule MvWeb.MemberLive.Index do
defp export_sort_order(:asc), do: "asc"
defp export_sort_order(:desc), do: "desc"
defp export_sort_order(o) when is_binary(o), do: o
+
+ # -------------------------------------------------------------
+ # Internal utility: dedupe dropdown fields defensively
+ # -------------------------------------------------------------
+
+ defp dedupe_available_fields(fields) when is_list(fields) do
+ Enum.uniq_by(fields, fn item ->
+ cond do
+ is_map(item) ->
+ Map.get(item, :key) || Map.get(item, :id) || Map.get(item, :field) || item
+
+ is_tuple(item) and tuple_size(item) >= 1 ->
+ elem(item, 0)
+
+ true ->
+ item
+ end
+ end)
+ end
+
+ defp dedupe_available_fields(other), do: other
end
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index 79017a9..543d16b 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -293,6 +293,7 @@
<:col
:let={member}
+ :if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")}
>
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
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 9ba9267..0b0cb67 100644
--- a/lib/mv_web/live/member_live/index/field_visibility.ex
+++ b/lib/mv_web/live/member_live/index/field_visibility.ex
@@ -18,10 +18,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
+
+ ## Pseudo Member Fields
+
+ Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only).
+ They appear in the field dropdown and in `member_fields_visible` but are not domain attributes.
"""
alias Mv.Membership.Helpers.VisibilityConfig
+ # Single UI key for "Membership Fee Status"; only this appears in the dropdown.
+ @pseudo_member_fields [:membership_fee_status]
+
+ # Export/API may accept this as alias; must not appear in the UI options list.
+ @export_only_alias :payment_status
+
+ defp overview_member_fields do
+ Mv.Constants.member_fields() ++ @pseudo_member_fields
+ end
+
@doc """
Gets all available fields for selection.
@@ -39,7 +54,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
- member_fields = Mv.Constants.member_fields()
+ member_fields =
+ overview_member_fields()
+ |> Enum.reject(fn field -> field == @export_only_alias end)
+
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
@@ -115,6 +133,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
+ |> Enum.uniq()
end
def get_visible_fields(_), do: []
@@ -132,7 +151,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
- member_fields = Mv.Constants.member_fields()
+ member_fields = overview_member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
@@ -140,10 +159,61 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
+ |> Enum.uniq()
end
def get_visible_member_fields(_), do: []
+ @doc """
+ Returns the list of computed (UI-only) member field atoms.
+
+ 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()]
+ def computed_member_fields, do: @pseudo_member_fields
+
+ @doc """
+ Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`).
+
+ Use for query select/sort. Not for rendering column visibility (use
+ `get_visible_member_fields/1` for that).
+ """
+ @spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()]
+ def get_visible_member_fields_db(field_selection) when is_map(field_selection) do
+ db_fields = MapSet.new(Mv.Constants.member_fields())
+
+ field_selection
+ |> Enum.filter(fn {field_string, visible} ->
+ field_atom = to_field_identifier(field_string)
+ visible && field_atom in db_fields
+ end)
+ |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
+ |> Enum.uniq()
+ end
+
+ def get_visible_member_fields_db(_), do: []
+
+ @doc """
+ Visible member fields that are computed/UI-only (e.g. membership_fee_status).
+
+ Use for rendering; do not use for query select or sort.
+ """
+ @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
+ def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
+ computed_set = MapSet.new(@pseudo_member_fields)
+
+ field_selection
+ |> Enum.filter(fn {field_string, visible} ->
+ field_atom = to_field_identifier(field_string)
+ visible && field_atom in computed_set
+ end)
+ |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
+ |> Enum.uniq()
+ end
+
+ def get_visible_member_fields_computed(_), do: []
+
@doc """
Gets visible custom fields from field selection.
@@ -176,19 +246,23 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
Map.merge(member_visibility, custom_field_visibility)
end
- # Gets member field visibility from settings
+ # Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
- member_fields = Mv.Constants.member_fields()
+ domain_fields = Mv.Constants.member_fields()
- Enum.reduce(member_fields, %{}, fn field, acc ->
- field_string = Atom.to_string(field)
- # exit_date defaults to false (hidden), all other fields default to true
- default_visibility = if field == :exit_date, do: false, else: true
- show_in_overview = Map.get(visibility_config, field, default_visibility)
- Map.put(acc, field_string, show_in_overview)
+ domain_map =
+ Enum.reduce(domain_fields, %{}, fn field, acc ->
+ field_string = Atom.to_string(field)
+ default_visibility = if field == :exit_date, do: false, else: true
+ show_in_overview = Map.get(visibility_config, field, default_visibility)
+ Map.put(acc, field_string, show_in_overview)
+ end)
+
+ Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
+ Map.put(acc, Atom.to_string(field), true)
end)
end
@@ -203,16 +277,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
- # Converts field string to atom (for member fields) or keeps as string (for custom fields)
+ # Converts field string to atom (for member fields) or keeps as string (for custom fields).
+ # Maps export-only alias to canonical UI key so only one option controls the column.
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string
else
- try do
- String.to_existing_atom(field_string)
- rescue
- ArgumentError -> field_string
- end
+ atom =
+ try do
+ String.to_existing_atom(field_string)
+ rescue
+ ArgumentError -> field_string
+ end
+
+ if atom == @export_only_alias, do: :membership_fee_status, else: atom
end
end
diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex
index 26f55ac..83ab139 100644
--- a/lib/mv_web/translations/member_fields.ex
+++ b/lib/mv_web/translations/member_fields.ex
@@ -28,6 +28,7 @@ defmodule MvWeb.Translations.MemberFields do
def label(:house_number), do: gettext("House Number")
def label(:postal_code), do: gettext("Postal Code")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
+ def label(:membership_fee_status), do: gettext("Membership Fee Status")
# Fallback for unknown fields
def label(field) do