diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex
index b0e7015..ea878a2 100644
--- a/lib/mv/authorization/permission_sets.ex
+++ b/lib/mv/authorization/permission_sets.ex
@@ -166,8 +166,9 @@ defmodule Mv.Authorization.PermissionSets do
"/users/:id",
"/users/:id/edit",
"/users/:id/show/edit",
- # Member list
+ # Member list and CSV export
"/members",
+ "/members/export.csv",
# Member detail
"/members/:id",
# Custom field values overview
@@ -223,6 +224,7 @@ defmodule Mv.Authorization.PermissionSets do
"/users/:id/edit",
"/users/:id/show/edit",
"/members",
+ "/members/export.csv",
# Create member
"/members/new",
"/members/:id",
diff --git a/lib/mv/membership/custom_field_value_formatter.ex b/lib/mv/membership/custom_field_value_formatter.ex
new file mode 100644
index 0000000..9709353
--- /dev/null
+++ b/lib/mv/membership/custom_field_value_formatter.ex
@@ -0,0 +1,55 @@
+defmodule Mv.Membership.CustomFieldValueFormatter do
+ @moduledoc """
+ Neutral formatter for custom field values (e.g. CSV export).
+
+ Same logic as the member overview Formatter but without Gettext or web helpers,
+ so it can be used from the Membership context. For boolean: "Yes"/"No";
+ for date: European format (dd.mm.yyyy).
+ """
+ @doc """
+ Formats a custom field value for plain text (e.g. CSV).
+
+ Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
+ for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
+ """
+ def format_custom_field_value(nil, _custom_field), do: ""
+
+ def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
+ format_value_by_type(value, type, custom_field)
+ end
+
+ def format_custom_field_value(value, custom_field) when is_map(value) do
+ type = Map.get(value, "type") || Map.get(value, "_union_type")
+ val = Map.get(value, "value") || Map.get(value, "_union_value")
+ format_value_by_type(val, type, custom_field)
+ end
+
+ def format_custom_field_value(value, custom_field) do
+ format_value_by_type(value, custom_field.value_type, custom_field)
+ end
+
+ defp format_value_by_type(value, :string, _), do: to_string(value)
+ defp format_value_by_type(value, :integer, _), do: to_string(value)
+
+ defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
+ if String.trim(value) == "", do: "", else: value
+ end
+
+ defp format_value_by_type(value, :email, _), do: to_string(value)
+ defp format_value_by_type(value, :boolean, _) when value == true, do: "Yes"
+ defp format_value_by_type(value, :boolean, _) when value == false, do: "No"
+ defp format_value_by_type(value, :boolean, _), do: to_string(value)
+
+ defp format_value_by_type(%Date{} = date, :date, _) do
+ Calendar.strftime(date, "%d.%m.%Y")
+ end
+
+ defp format_value_by_type(value, :date, _) when is_binary(value) do
+ case Date.from_iso8601(value) do
+ {:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
+ _ -> value
+ end
+ end
+
+ defp format_value_by_type(value, _type, _), do: to_string(value)
+end
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
new file mode 100644
index 0000000..a0fd463
--- /dev/null
+++ b/lib/mv/membership/members_csv.ex
@@ -0,0 +1,100 @@
+defmodule Mv.Membership.MembersCSV do
+ @moduledoc """
+ Exports members to CSV (RFC 4180) as iodata.
+
+ 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
+
+ @doc """
+ Exports a list of members to CSV iodata.
+
+ - `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() | 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 build_header(columns) do
+ columns
+ |> Enum.map(fn col -> col.header end)
+ |> Enum.map(&safe_cell/1)
+ end
+
+ defp build_row(member, columns) do
+ columns
+ |> Enum.map(fn col -> cell_value(member, col) end)
+ |> Enum.map(&safe_cell/1)
+ end
+
+ 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 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(k)
+ rescue
+ 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"
+ defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
+ 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)
+end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 9ef8f2b..60f3636 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -179,7 +179,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"
@@ -233,11 +234,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
new file mode 100644
index 0000000..57ce630
--- /dev/null
+++ b/lib/mv_web/controllers/member_export_controller.ex
@@ -0,0 +1,433 @@
+defmodule MvWeb.MemberExportController do
+ @moduledoc """
+ Controller for CSV export of members.
+
+ POST /members/export.csv with form param "payload" (JSON string).
+ Same permission and actor context as the member overview; 403 if unauthorized.
+ """
+ use MvWeb, :controller
+
+ require Ash.Query
+ import Ash.Expr
+
+ alias Mv.Authorization.Actor
+ alias Mv.Membership.CustomField
+ alias Mv.Membership.Member
+ alias Mv.Membership.MembersCSV
+
+ @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+ @custom_field_prefix Mv.Constants.custom_field_prefix()
+
+ def export(conn, params) do
+ actor = current_actor(conn)
+ if is_nil(actor), do: return_forbidden(conn)
+
+ case params["payload"] do
+ nil ->
+ conn
+ |> put_status(400)
+ |> put_resp_content_type("application/json")
+ |> json(%{error: "payload required"})
+
+ payload when is_binary(payload) ->
+ case Jason.decode(payload) do
+ {:ok, decoded} when is_map(decoded) ->
+ parsed = parse_and_validate(decoded)
+ run_export(conn, actor, parsed)
+
+ _ ->
+ conn
+ |> put_status(400)
+ |> put_resp_content_type("application/json")
+ |> json(%{error: "invalid JSON"})
+ end
+ end
+ end
+
+ defp current_actor(conn) do
+ conn.assigns[:current_user]
+ |> Actor.ensure_loaded()
+ end
+
+ defp return_forbidden(conn) do
+ conn
+ |> put_status(403)
+ |> put_resp_content_type("application/json")
+ |> json(%{error: "Forbidden"})
+ |> halt()
+ end
+
+ defp parse_and_validate(params) 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"),
+ sort_order: extract_sort_order(params)
+ }
+ 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
+ _ -> []
+ 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
+
+ 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
+ columns = build_columns(conn, parsed, custom_fields_by_id)
+ csv_iodata = MembersCSV.export(members, columns)
+ filename = "members-#{Date.utc_today()}.csv"
+
+ send_download(
+ conn,
+ {:binary, IO.iodata_to_binary(csv_iodata)},
+ filename: filename,
+ content_type: "text/csv; charset=utf-8"
+ )
+ else
+ {:error, :forbidden} ->
+ return_forbidden(conn)
+ 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
+ query =
+ CustomField
+ |> Ash.Query.filter(expr(id in ^custom_field_ids))
+ |> Ash.Query.select([:id, :name, :value_type])
+
+ query
+ |> Ash.read(actor: actor)
+ |> handle_custom_fields_read_result(custom_field_ids)
+ end
+
+ defp handle_custom_fields_read_result({:ok, custom_fields}, custom_field_ids) do
+ by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
+ {:ok, by_id}
+ end
+
+ defp handle_custom_fields_read_result({:error, %Ash.Error.Forbidden{}}, _custom_field_ids) do
+ {:error, :forbidden}
+ end
+
+ defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
+ 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)
+ 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)
+
+ query =
+ Member
+ |> Ash.Query.new()
+ |> Ash.Query.select(select_fields)
+ |> load_custom_field_values_query(parsed.custom_field_ids)
+
+ 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)
+ 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 sort_after_load do
+ sort_members_by_custom_field_export(
+ 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_export(query, nil), do: query
+ defp apply_search_export(query, ""), do: query
+
+ defp apply_search_export(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_export(query, nil, _order), do: {query, false}
+ defp maybe_sort_export(query, _field, nil), do: {query, false}
+
+ defp maybe_sort_export(query, field, order) when is_binary(field) do
+ cond do
+ custom_field_sort?(field) ->
+ # Custom field sort → in-memory nach dem Read (wie Tabelle)
+ {query, true}
+
+ 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 custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
+
+ # ------------------------------------------------------------------
+ # Custom field sorting (match member table behavior)
+ # ------------------------------------------------------------------
+
+ defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields)
+ when members == [],
+ 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
+ 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)
+
+ 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 ->
+ 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 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)
+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/import_export_live.ex b/lib/mv_web/live/import_export_live.ex
index 384c39b..86c3e1f 100644
--- a/lib/mv_web/live/import_export_live.ex
+++ b/lib/mv_web/live/import_export_live.ex
@@ -642,24 +642,48 @@ defmodule MvWeb.ImportExportLive do
# Start async task to process chunk in production
# Use start_child for fire-and-forget: no monitor, no Task messages
# We only use our own send/2 messages for communication
- Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
- # Set locale in task process for translations
- Gettext.put_locale(MvWeb.Gettext, locale)
-
- process_chunk_with_error_handling(
+ Task.Supervisor.start_child(
+ Mv.TaskSupervisor,
+ build_chunk_processing_task(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
- idx
+ idx,
+ locale
)
- end)
+ )
end
{:noreply, socket}
end
+ # Builds the task function for processing a chunk asynchronously.
+ defp build_chunk_processing_task(
+ chunk,
+ column_map,
+ custom_field_map,
+ opts,
+ live_view_pid,
+ idx,
+ locale
+ ) do
+ fn ->
+ # Set locale in task process for translations
+ Gettext.put_locale(MvWeb.Gettext, locale)
+
+ process_chunk_with_error_handling(
+ chunk,
+ column_map,
+ custom_field_map,
+ opts,
+ live_view_pid,
+ idx
+ )
+ end
+ end
+
# Handles chunk processing result from async task and schedules the next chunk.
@spec handle_chunk_result(
Phoenix.LiveView.Socket.t(),
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index 673502d..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,10 +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
@@ -229,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,
@@ -299,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
@@ -327,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
@@ -345,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
@@ -378,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,
@@ -390,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
@@ -416,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,
@@ -428,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)
@@ -447,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,
@@ -459,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,
@@ -483,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()
@@ -502,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,
@@ -513,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()
@@ -533,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,
@@ -556,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,
@@ -564,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)
@@ -579,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()
@@ -603,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,
@@ -630,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
}
@@ -665,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)}
@@ -677,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,
@@ -699,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}")
@@ -709,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
@@ -734,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
@@ -751,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(
@@ -765,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,
@@ -807,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
@@ -815,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")
@@ -823,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"
@@ -831,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
@@ -858,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)
@@ -872,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
@@ -926,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
@@ -942,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))
@@ -971,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)
@@ -996,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)
@@ -1051,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
@@ -1074,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
@@ -1086,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 ->
@@ -1170,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 ->
@@ -1179,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
@@ -1205,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)
@@ -1235,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
@@ -1288,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} =
@@ -1359,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})"
@@ -1375,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
@@ -1442,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 ->
@@ -1497,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")
@@ -1551,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 ->
@@ -1656,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 ->
@@ -1667,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 || ""
@@ -1688,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
@@ -1729,5 +1363,82 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:selected_count, selected_count)
|> assign(:any_selected?, any_selected?)
|> assign(:mailto_bcc, mailto_bcc)
+ |> assign_export_payload()
end
+
+ 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
+ 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(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]),
+ 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
+
+ defp export_sort_order(nil), do: nil
+ 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 11ade49..381cd63 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -2,6 +2,20 @@
<.header>
{gettext("Members")}
<:actions>
+
<.button
class="secondary"
id="copy-emails-btn"
@@ -282,6 +296,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/router.ex b/lib/mv_web/router.ex
index b5bc616..97e0642 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -91,6 +91,7 @@ defmodule MvWeb.Router do
# Import/Export (Admin only)
live "/admin/import-export", ImportExportLive
+ post "/members/export.csv", MemberExportController, :export
post "/set_locale", LocaleController, :set_locale
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
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 6ba8022..e594bc9 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -2318,52 +2318,20 @@ msgstr "Mitgliederdaten verwalten"
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
-#: lib/mv/membership/member/validations/email_change_permission.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Only administrators or the linked user can change the email for members linked to users"
-msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind."
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Custom Fields in CSV Import"
+#~ msgstr "Benutzerdefinierte Felder"
-#: lib/mv_web/live/user_live/form.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Select role..."
-msgstr "Keine auswählen"
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Failed to prepare CSV import: %{error}"
+#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
-#: lib/mv_web/live/member_live/show/membership_fees_component.ex
-#, elixir-autogen, elixir-format
-msgid "You are not allowed to perform this action."
-msgstr "Du hast keine Berechtigung, diese Aktion auszuführen."
-
-#: lib/mv_web/live/member_live/form.ex
-#, elixir-autogen, elixir-format
-msgid "Select a membership fee type"
-msgstr "Mitgliedsbeitragstyp auswählen"
-
-#: lib/mv_web/live/user_live/index.html.heex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Linked"
-msgstr "Verknüpft"
-
-#: lib/mv_web/live/user_live/index.html.heex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "OIDC"
-msgstr "OIDC"
-
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Not linked"
-msgstr "Nicht verknüpft"
-
-#: lib/mv_web/live/user_live/form.ex
-#, elixir-autogen, elixir-format
-msgid "SSO / OIDC user"
-msgstr "SSO-/OIDC-Benutzer*in"
-
-#: lib/mv_web/live/user_live/form.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
-msgstr "Dieser*e Benutzer*in ist per SSO (Single Sign-On) angebunden. Ein hier gesetztes oder geändertes Passwort betrifft nur die Anmeldung mit E-Mail und Passwort in dieser Anwendung. Es ändert nicht das Passwort beim Identity-Provider (z. B. Authentik). Zum Ändern des SSO-Passworts nutzen Sie den Identity-Provider oder die IT Ihrer Organisation."
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
+#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwende den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert."
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index ace001a..fb02166 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -2319,6 +2319,21 @@ msgstr ""
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Export members to CSV"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Export to CSV"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "all"
+msgstr ""
+
#: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users"
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 510909c..7608376 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -2319,52 +2319,35 @@ msgstr ""
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
-#: lib/mv/membership/member/validations/email_change_permission.ex
+#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
-msgid "Only administrators or the linked user can change the email for members linked to users"
-msgstr "Only administrators or the linked user can change the email for members linked to users"
-
-#: lib/mv_web/live/user_live/form.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Select role..."
+msgid "Export members to CSV"
msgstr ""
-#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
-msgid "You are not allowed to perform this action."
+msgid "Export to CSV"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Select a membership fee type"
-msgstr ""
-
-#: lib/mv_web/live/user_live/index.html.heex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Linked"
-msgstr ""
-
-#: lib/mv_web/live/user_live/index.html.heex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
-msgid "OIDC"
+msgid "all"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Not linked"
-msgstr ""
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Custom Fields in CSV Import"
+#~ msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#, elixir-autogen, elixir-format
-msgid "SSO / OIDC user"
-msgstr ""
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Failed to prepare CSV import: %{error}"
+#~ msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
-msgstr ""
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
+#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
diff --git a/test/mv/membership/member_export_sort_test.exs b/test/mv/membership/member_export_sort_test.exs
new file mode 100644
index 0000000..812a386
--- /dev/null
+++ b/test/mv/membership/member_export_sort_test.exs
@@ -0,0 +1,90 @@
+defmodule Mv.Membership.MemberExportSortTest do
+ use ExUnit.Case, async: true
+
+ alias Mv.Membership.MemberExportSort
+
+ describe "custom_field_sort_key/2" do
+ test "nil has rank 1 (sorts last in asc, first in desc)" do
+ assert MemberExportSort.custom_field_sort_key(:string, nil) == {1, nil}
+ assert MemberExportSort.custom_field_sort_key(:date, nil) == {1, nil}
+ end
+
+ test "date: chronological key (ISO8601 string)" do
+ earlier = ~D[2023-01-15]
+ later = ~D[2024-06-01]
+ assert MemberExportSort.custom_field_sort_key(:date, earlier) == {0, "2023-01-15"}
+ assert MemberExportSort.custom_field_sort_key(:date, later) == {0, "2024-06-01"}
+ assert {0, "2023-01-15"} < {0, "2024-06-01"}
+ end
+
+ test "date + nil: nil sorts after any date in asc" do
+ key_date = MemberExportSort.custom_field_sort_key(:date, ~D[2024-01-01])
+ key_nil = MemberExportSort.custom_field_sort_key(:date, nil)
+ assert key_date == {0, "2024-01-01"}
+ assert key_nil == {1, nil}
+ assert key_date < key_nil
+ end
+
+ test "boolean: false < true" do
+ key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
+ key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
+ assert key_f == {0, 0}
+ assert key_t == {0, 1}
+ assert key_f < key_t
+ end
+
+ test "boolean + nil: nil sorts after false and true in asc" do
+ key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
+ key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
+ key_nil = MemberExportSort.custom_field_sort_key(:boolean, nil)
+ assert key_f < key_nil and key_t < key_nil
+ end
+
+ test "integer: numerical key" do
+ assert MemberExportSort.custom_field_sort_key(:integer, 10) == {0, 10}
+ assert MemberExportSort.custom_field_sort_key(:integer, -5) == {0, -5}
+ assert MemberExportSort.custom_field_sort_key(:integer, 0) == {0, 0}
+ assert {0, -5} < {0, 0} and {0, 0} < {0, 10}
+ end
+
+ test "string: case-insensitive key (downcased)" do
+ key_a = MemberExportSort.custom_field_sort_key(:string, "Anna")
+ key_b = MemberExportSort.custom_field_sort_key(:string, "bert")
+ assert key_a == {0, "anna"}
+ assert key_b == {0, "bert"}
+ assert key_a < key_b
+ end
+
+ test "email: case-insensitive key" do
+ assert MemberExportSort.custom_field_sort_key(:email, "User@Example.com") ==
+ {0, "user@example.com"}
+ end
+
+ test "Ash.Union value is unwrapped" do
+ union = %Ash.Union{value: ~D[2024-01-01], type: :date}
+ assert MemberExportSort.custom_field_sort_key(:date, union) == {0, "2024-01-01"}
+ end
+ end
+
+ describe "key_lt/3" do
+ test "asc: smaller key first, nil last" do
+ k_nil = {1, nil}
+ k_early = {0, "2023-01-01"}
+ k_late = {0, "2024-01-01"}
+ refute MemberExportSort.key_lt(k_nil, k_early, "asc")
+ refute MemberExportSort.key_lt(k_nil, k_late, "asc")
+ assert MemberExportSort.key_lt(k_early, k_late, "asc")
+ assert MemberExportSort.key_lt(k_early, k_nil, "asc")
+ end
+
+ test "desc: larger key first, nil first" do
+ k_nil = {1, nil}
+ k_early = {0, "2023-01-01"}
+ k_late = {0, "2024-01-01"}
+ assert MemberExportSort.key_lt(k_nil, k_early, "desc")
+ assert MemberExportSort.key_lt(k_nil, k_late, "desc")
+ assert MemberExportSort.key_lt(k_late, k_early, "desc")
+ refute MemberExportSort.key_lt(k_early, k_nil, "desc")
+ end
+ end
+end
diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs
new file mode 100644
index 0000000..a8688bf
--- /dev/null
+++ b/test/mv/membership/members_csv_test.exs
@@ -0,0 +1,293 @@
+defmodule Mv.Membership.MembersCSVTest do
+ use ExUnit.Case, async: true
+
+ alias Mv.Membership.MembersCSV
+
+ describe "export/2" do
+ test "returns CSV with header and one data row (member fields only)" do
+ member = %{first_name: "Jane", email: "jane@example.com"}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"}
+ ]
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "First Name"
+ assert csv =~ "Email"
+ assert csv =~ "Jane"
+ assert csv =~ "jane@example.com"
+ lines = String.split(csv, "\n", trim: true)
+ assert length(lines) == 2
+ end
+
+ test "header uses display labels not raw field names (regression guard)" do
+ member = %{first_name: "Jane", email: "jane@example.com"}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"}
+ ]
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+ header_line = csv |> String.split("\n", trim: true) |> hd()
+
+ assert header_line =~ "First Name"
+ assert header_line =~ "Email"
+ refute header_line =~ "first_name"
+ refute header_line =~ "email"
+ end
+
+ test "escapes cell containing comma (RFC 4180 quoted)" do
+ member = %{first_name: "Doe, John", email: "john@example.com"}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"}
+ ]
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ ~s("Doe, John")
+ assert csv =~ "john@example.com"
+ end
+
+ test "escapes cell containing double-quote (RFC 4180 doubled and quoted)" do
+ member = %{first_name: ~s(He said "Hi"), email: "a@b.com"}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"}
+ ]
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ ~s("He said ""Hi""")
+ assert csv =~ "a@b.com"
+ end
+
+ test "formats date as ISO8601 for member fields" do
+ member = %{first_name: "D", email: "d@d.com", join_date: ~D[2024-03-15]}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"},
+ %{header: "Join Date", kind: :member_field, key: "join_date"}
+ ]
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "2024-03-15"
+ assert csv =~ "Join Date"
+ end
+
+ test "formats nil as empty string" do
+ member = %{first_name: "Only", last_name: nil, email: "x@y.com"}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Last Name", kind: :member_field, key: "last_name"},
+ %{header: "Email", kind: :member_field, key: "email"}
+ ]
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "First Name"
+ assert csv =~ "Only"
+ assert csv =~ "x@y.com"
+ assert csv =~ "Only,,x@y"
+ end
+
+ test "custom field column uses header and formats value" do
+ custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"},
+ %{header: "Active", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
+ ]
+
+ member = %{
+ first_name: "Test",
+ email: "e@e.com",
+ custom_field_values: [
+ %{custom_field_id: "cf-1", value: true, custom_field: custom_cf}
+ ]
+ }
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "Active"
+ assert csv =~ "Yes"
+ end
+
+ test "custom field uses display_name when present, else name" do
+ custom_cf = %{id: "cf-a", name: "FieldA", value_type: :string}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{
+ header: "Display Label",
+ kind: :custom_field,
+ key: "cf-a",
+ custom_field: Map.put(custom_cf, :display_name, "Display Label")
+ }
+ ]
+
+ member = %{
+ first_name: "X",
+ email: "x@x.com",
+ custom_field_values: [
+ %{custom_field_id: "cf-a", value: "only_a", custom_field: custom_cf}
+ ]
+ }
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "Display Label"
+ assert csv =~ "only_a"
+ end
+
+ test "missing custom field value yields empty cell" do
+ cf1 = %{id: "cf-a", name: "FieldA", value_type: :string}
+ cf2 = %{id: "cf-b", name: "FieldB", value_type: :string}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"},
+ %{header: "FieldA", kind: :custom_field, key: "cf-a", custom_field: cf1},
+ %{header: "FieldB", kind: :custom_field, key: "cf-b", custom_field: cf2}
+ ]
+
+ member = %{
+ first_name: "X",
+ email: "x@x.com",
+ custom_field_values: [%{custom_field_id: "cf-a", value: "only_a", custom_field: cf1}]
+ }
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "First Name,Email,FieldA,FieldB"
+ assert csv =~ "only_a"
+ assert csv =~ "X,x@x.com,only_a,"
+ end
+
+ test "computed column exports membership fee status label" do
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"},
+ %{header: "Membership Fee Status", kind: :computed, key: :membership_fee_status}
+ ]
+
+ member = %{first_name: "M", email: "m@m.com", membership_fee_status: "Paid"}
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "Membership Fee Status"
+ assert csv =~ "Paid"
+ assert csv =~ "M,m@m.com,Paid"
+ end
+
+ test "computed column with payment_status key exports same value (alias)" do
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Membership Fee Status", kind: :computed, key: :payment_status}
+ ]
+
+ member = %{first_name: "X", payment_status: "Unpaid"}
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "Membership Fee Status"
+ assert csv =~ "Unpaid"
+ assert csv =~ "X,Unpaid"
+ end
+
+ test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
+ member = %{
+ first_name: "=SUM(A1:A10)",
+ last_name: "+1",
+ email: "@cmd|evil"
+ }
+
+ custom_cf = %{id: "cf-1", name: "Note", value_type: :string}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Last Name", kind: :member_field, key: "last_name"},
+ %{header: "Email", kind: :member_field, key: "email"},
+ %{header: "Note", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
+ ]
+
+ member_with_cf =
+ Map.put(member, :custom_field_values, [
+ %{custom_field_id: "cf-1", value: "normal text", custom_field: custom_cf}
+ ])
+
+ iodata = MembersCSV.export([member_with_cf], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "'=SUM(A1:A10)"
+ assert csv =~ "'+1"
+ assert csv =~ "'@cmd|evil"
+ assert csv =~ "normal text"
+ refute csv =~ ",'normal text"
+ end
+
+ test "CSV injection: minus and tab prefix are escaped" do
+ member = %{first_name: "-2", last_name: "\tleading", email: "safe@x.com"}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Last Name", kind: :member_field, key: "last_name"},
+ %{header: "Email", kind: :member_field, key: "email"}
+ ]
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "'-2"
+ assert csv =~ "'\tleading"
+ assert csv =~ "safe@x.com"
+ end
+
+ test "column order is preserved (headers and values)" do
+ cf1 = %{id: "a", name: "Custom1", value_type: :string}
+ cf2 = %{id: "b", name: "Custom2", value_type: :string}
+
+ columns = [
+ %{header: "First Name", kind: :member_field, key: "first_name"},
+ %{header: "Email", kind: :member_field, key: "email"},
+ %{header: "Custom2", kind: :custom_field, key: "b", custom_field: cf2},
+ %{header: "Custom1", kind: :custom_field, key: "a", custom_field: cf1}
+ ]
+
+ member = %{
+ first_name: "M",
+ email: "m@m.com",
+ custom_field_values: [
+ %{custom_field_id: "a", value: "v1", custom_field: cf1},
+ %{custom_field_id: "b", value: "v2", custom_field: cf2}
+ ]
+ }
+
+ iodata = MembersCSV.export([member], columns)
+ csv = IO.iodata_to_binary(iodata)
+
+ assert csv =~ "First Name,Email,Custom2,Custom1"
+ assert csv =~ "M,m@m.com,v2,v1"
+ end
+ end
+end
diff --git a/test/mv_web/components/search_bar_component_test.exs b/test/mv_web/components/search_bar_component_test.exs
index bc8bc46..1043c1f 100644
--- a/test/mv_web/components/search_bar_component_test.exs
+++ b/test/mv_web/components/search_bar_component_test.exs
@@ -15,19 +15,19 @@ defmodule MvWeb.Components.SearchBarComponentTest do
{:ok, view, _html} = live(conn, "/members")
# simulate search input and check that other members are not listed
- html =
+ _html =
view
|> element("form[role=search]")
|> render_submit(%{"query" => "Friedrich"})
- refute html =~ "Greta"
+ refute has_element?(view, "input[data-testid='search-input'][value='Greta']")
- html =
+ _html =
view
|> element("form[role=search]")
|> render_submit(%{"query" => "Greta"})
- refute html =~ "Friedrich"
+ refute has_element?(view, "input[data-testid='search-input'][value='Friedrich']")
end
end
end
diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs
new file mode 100644
index 0000000..34f5a75
--- /dev/null
+++ b/test/mv_web/controllers/member_export_controller_test.exs
@@ -0,0 +1,243 @@
+defmodule MvWeb.MemberExportControllerTest do
+ use MvWeb.ConnCase, async: true
+
+ alias Mv.Fixtures
+
+ defp csrf_token_from_conn(conn) do
+ get_session(conn, "_csrf_token") || csrf_token_from_html(response(conn, 200))
+ end
+
+ defp csrf_token_from_html(html) when is_binary(html) do
+ case Regex.run(~r/name="csrf-token"\s+content="([^"]+)"/, html) do
+ [_, token] -> token
+ _ -> nil
+ end
+ end
+
+ describe "POST /members/export.csv" do
+ setup %{conn: conn} do
+ # Create 3 members for export tests
+ m1 =
+ Fixtures.member_fixture(%{
+ first_name: "Alice",
+ last_name: "One",
+ email: "alice.one@example.com"
+ })
+
+ m2 =
+ Fixtures.member_fixture(%{
+ first_name: "Bob",
+ last_name: "Two",
+ email: "bob.two@example.com"
+ })
+
+ m3 =
+ Fixtures.member_fixture(%{
+ first_name: "Carol",
+ last_name: "Three",
+ email: "carol.three@example.com"
+ })
+
+ %{member1: m1, member2: m2, member3: m3, conn: conn}
+ end
+
+ test "selected export: returns 200, text/csv, header + exactly 2 data rows", %{
+ conn: conn,
+ member1: m1,
+ member2: m2
+ } do
+ payload = %{
+ "selected_ids" => [m1.id, m2.id],
+ "member_fields" => ["first_name", "last_name", "email"],
+ "custom_field_ids" => [],
+ "query" => nil,
+ "sort_field" => nil,
+ "sort_order" => nil
+ }
+
+ conn = get(conn, "/members")
+ csrf_token = csrf_token_from_conn(conn)
+
+ conn =
+ post(conn, "/members/export.csv", %{
+ "payload" => Jason.encode!(payload),
+ "_csrf_token" => csrf_token
+ })
+
+ assert conn.status == 200
+ assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
+
+ body = response(conn, 200)
+ lines = String.split(body, "\n", trim: true)
+
+ # Header + 2 data rows (headers are localized labels)
+ assert length(lines) == 3
+ assert hd(lines) =~ "First Name"
+ assert hd(lines) =~ "Email"
+ assert body =~ "Alice"
+ assert body =~ "Bob"
+ refute body =~ "Carol"
+ end
+
+ test "all export: selected_ids=[] returns all members (at least 3 data rows)", %{
+ conn: conn,
+ member1: _m1,
+ member2: _m2,
+ member3: _m3
+ } do
+ payload = %{
+ "selected_ids" => [],
+ "member_fields" => ["first_name", "email"],
+ "custom_field_ids" => [],
+ "query" => nil,
+ "sort_field" => nil,
+ "sort_order" => nil
+ }
+
+ conn = get(conn, "/members")
+ csrf_token = csrf_token_from_conn(conn)
+
+ conn =
+ post(conn, "/members/export.csv", %{
+ "payload" => Jason.encode!(payload),
+ "_csrf_token" => csrf_token
+ })
+
+ assert conn.status == 200
+ body = response(conn, 200)
+ lines = String.split(body, "\n", trim: true)
+
+ # Header + at least 3 data rows (headers are localized labels)
+ assert length(lines) >= 4
+ assert hd(lines) =~ "First Name"
+ assert body =~ "Alice"
+ assert body =~ "Bob"
+ assert body =~ "Carol"
+ end
+
+ test "whitelist: unknown member_fields are not in header", %{conn: conn, member1: m1} do
+ payload = %{
+ "selected_ids" => [m1.id],
+ "member_fields" => ["first_name", "unknown_field", "email"],
+ "custom_field_ids" => [],
+ "query" => nil,
+ "sort_field" => nil,
+ "sort_order" => nil
+ }
+
+ conn = get(conn, "/members")
+ csrf_token = csrf_token_from_conn(conn)
+
+ conn =
+ post(conn, "/members/export.csv", %{
+ "payload" => Jason.encode!(payload),
+ "_csrf_token" => csrf_token
+ })
+
+ assert conn.status == 200
+ body = response(conn, 200)
+ header = body |> String.split("\n", trim: true) |> hd()
+
+ assert header =~ "First Name"
+ assert header =~ "Email"
+ refute header =~ "unknown_field"
+ end
+
+ test "export includes membership_fee_status column when requested", %{
+ conn: conn,
+ member1: m1
+ } do
+ payload = %{
+ "selected_ids" => [m1.id],
+ "member_fields" => ["first_name", "membership_fee_status"],
+ "custom_field_ids" => [],
+ "query" => nil,
+ "sort_field" => nil,
+ "sort_order" => nil
+ }
+
+ conn = get(conn, "/members")
+ csrf_token = csrf_token_from_conn(conn)
+
+ conn =
+ post(conn, "/members/export.csv", %{
+ "payload" => Jason.encode!(payload),
+ "_csrf_token" => csrf_token
+ })
+
+ assert conn.status == 200
+ body = response(conn, 200)
+ header = body |> String.split("\n", trim: true) |> hd()
+
+ assert header =~ "First Name"
+ assert header =~ "Membership Fee Status"
+ assert body =~ "Alice"
+ end
+
+ test "export with payment_status alias: header shows Membership Fee Status", %{
+ conn: conn,
+ member1: m1
+ } do
+ payload = %{
+ "selected_ids" => [m1.id],
+ "member_fields" => ["first_name", "payment_status"],
+ "custom_field_ids" => [],
+ "query" => nil,
+ "sort_field" => nil,
+ "sort_order" => nil
+ }
+
+ conn = get(conn, "/members")
+ csrf_token = csrf_token_from_conn(conn)
+
+ conn =
+ post(conn, "/members/export.csv", %{
+ "payload" => Jason.encode!(payload),
+ "_csrf_token" => csrf_token
+ })
+
+ assert conn.status == 200
+ body = response(conn, 200)
+ header = body |> String.split("\n", trim: true) |> hd()
+
+ assert header =~ "Membership Fee Status"
+ assert body =~ "Alice"
+ end
+
+ test "export with show_current_cycle: membership fee status column exists stably", %{
+ conn: conn,
+ member1: _m1,
+ member2: _m2,
+ member3: _m3
+ } do
+ payload = %{
+ "selected_ids" => [],
+ "member_fields" => ["first_name", "email", "membership_fee_status"],
+ "custom_field_ids" => [],
+ "query" => nil,
+ "sort_field" => nil,
+ "sort_order" => nil,
+ "show_current_cycle" => true
+ }
+
+ conn = get(conn, "/members")
+ csrf_token = csrf_token_from_conn(conn)
+
+ conn =
+ post(conn, "/members/export.csv", %{
+ "payload" => Jason.encode!(payload),
+ "_csrf_token" => csrf_token
+ })
+
+ assert conn.status == 200
+ body = response(conn, 200)
+ lines = String.split(body, "\n", trim: true)
+
+ assert length(lines) >= 4
+ header = hd(lines)
+ assert header =~ "First Name"
+ assert header =~ "Email"
+ assert header =~ "Membership Fee Status"
+ end
+ end
+end
diff --git a/test/mv_web/live/import_export_live_test.exs b/test/mv_web/live/import_export_live_test.exs
index a165ea6..653cd8d 100644
--- a/test/mv_web/live/import_export_live_test.exs
+++ b/test/mv_web/live/import_export_live_test.exs
@@ -19,6 +19,7 @@ defmodule MvWeb.ImportExportLiveTest do
end
describe "Import/Export LiveView" do
+ @describetag :ui
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
@@ -45,6 +46,7 @@ defmodule MvWeb.ImportExportLiveTest do
end
describe "CSV Import Section" do
+ @describetag :ui
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
@@ -524,6 +526,7 @@ defmodule MvWeb.ImportExportLiveTest do
# Verified by import-results-panel existence above
end
+ @tag :ui
test "A11y: file input has label", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
@@ -532,6 +535,7 @@ defmodule MvWeb.ImportExportLiveTest do
html =~ ~r/