diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index 858748d..ea0ddff 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -158,8 +158,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 @@ -208,6 +209,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/members_csv.ex b/lib/mv/membership/members_csv.ex new file mode 100644 index 0000000..6eab399 --- /dev/null +++ b/lib/mv/membership/members_csv.ex @@ -0,0 +1,91 @@ +defmodule Mv.Membership.MembersCSV do + @moduledoc """ + Exports members to CSV (RFC 4180) as iodata. + + Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings; + custom field values use the same formatting logic as the member overview (neutral formatter). + Column order for custom fields follows the key order of the `custom_fields_by_id` map. + """ + alias Mv.Membership.CustomFieldValueFormatter + alias NimbleCSV.RFC4180 + + @doc """ + Exports a list of members to CSV iodata. + + - `members` - List of member structs (with optional `custom_field_values` loaded) + - `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`) + - `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order. + + Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body. + """ + @spec export( + [struct()], + [String.t()], + %{optional(String.t() | Ecto.UUID.t()) => struct()} + ) :: iodata() + def export(members, member_fields, custom_fields_by_id) when is_list(members) do + custom_entries = custom_field_entries(custom_fields_by_id) + header = build_header(member_fields, custom_entries) + rows = Enum.map(members, &build_row(&1, member_fields, custom_entries)) + RFC4180.dump_to_iodata([header | rows]) + end + + defp custom_field_entries(by_id) when is_map(by_id) do + Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end) + end + + defp build_header(member_fields, custom_entries) do + member_headers = member_fields + custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end) + member_headers ++ custom_headers + end + + defp build_row(member, member_fields, custom_entries) do + member_cells = Enum.map(member_fields, &format_member_field(member, &1)) + + custom_cells = + Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end) + + member_cells ++ custom_cells + end + + defp format_member_field(member, field_name) do + key = member_field_key(field_name) + value = Map.get(member, key) + format_member_value(value) + end + + defp member_field_key(field_name) when is_binary(field_name) do + try do + String.to_existing_atom(field_name) + rescue + ArgumentError -> field_name + end + 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) + + defp format_custom_field(member, custom_field_id, custom_field) do + cfv = find_custom_field_value(member, custom_field_id) + + if cfv, + do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field), + else: "" + end + + defp find_custom_field_value(member, custom_field_id) do + values = Map.get(member, :custom_field_values) || [] + id_str = to_string(custom_field_id) + + Enum.find(values, fn cfv -> + to_string(cfv.custom_field_id) == id_str or + (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str) + end) + end +end diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex new file mode 100644 index 0000000..801bf0a --- /dev/null +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -0,0 +1,291 @@ +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.Membership.Member + alias Mv.Membership.CustomField + alias Mv.Membership.MembersCSV + alias Mv.Authorization.Actor + + @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")), + 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 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 + with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor), + {:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do + csv_iodata = MembersCSV.export(members, parsed.member_fields, custom_fields_by_id) + 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 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 = build_custom_fields_by_id(custom_field_ids, custom_fields) + {:ok, by_id} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp build_custom_fields_by_id(custom_field_ids, custom_fields) do + Enum.reduce(custom_field_ids, %{}, fn id, acc -> + find_and_add_custom_field(acc, id, custom_fields) + end) + end + + defp find_and_add_custom_field(acc, id, custom_fields) do + case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do + nil -> acc + cf -> Map.put(acc, id, cf) + end + end + + defp load_members_for_export(actor, parsed, custom_fields_by_id) do + select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1) + + 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 + Ash.Query.filter(query, expr(id in ^parsed.selected_ids)) + else + query + |> apply_search_export(parsed.query) + |> then(fn q -> + {q, _sort_after_load} = maybe_sort_export(q, parsed.sort_field, parsed.sort_order) + q + end) + end + + case Ash.read(query, actor: actor) do + {:ok, members} -> + members = + if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do + sort_members_by_custom_field_export( + members, + parsed.sort_field, + parsed.sort_order, + Map.values(custom_fields_by_id) + ) + else + # selected_ids != []: no sort. selected_ids == [] and DB sort: already in query. + 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 + 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_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 + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + if is_nil(custom_field), do: members + + extract_sort_val = fn member -> + cfv = find_cfv(member, custom_field) + if cfv, do: extract_sort_value(cfv.value, custom_field.value_type), else: nil + end + + sorted = + members + |> Enum.sort_by(extract_sort_val, fn + nil, _ -> false + _, nil -> true + a, b -> if order == "desc", do: a >= b, else: a <= b + end) + + if order == "desc", do: Enum.reverse(sorted), else: sorted + 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 extract_sort_value(%Ash.Union{value: value, type: 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 + defp extract_sort_value(value, :boolean) when is_boolean(value), do: value + defp extract_sort_value(%Date{} = d, :date), do: d + defp extract_sort_value(value, :email) when is_binary(value), do: value + defp extract_sort_value(value, _), do: to_string(value) + + defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix) +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 673502d..acafcf3 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -131,6 +131,7 @@ defmodule MvWeb.MemberLive.Index do ) |> 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} @@ -1729,5 +1730,36 @@ defmodule MvWeb.MemberLive.Index do |> assign(:selected_count, selected_count) |> assign(:any_selected?, any_selected?) |> assign(:mailto_bcc, mailto_bcc) + |> assign_export_payload() end + + # Builds the export payload map and assigns :export_payload_json for the CSV export form. + # Called when selection, visible fields, query, or sort change so the form always has current data. + defp assign_export_payload(socket) do + payload = build_export_payload(socket) + assign(socket, :export_payload_json, Jason.encode!(payload)) + end + + defp build_export_payload(socket) do + member_fields_visible = socket.assigns[:member_fields_visible] || [] + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + + %{ + selected_ids: socket.assigns.selected_members |> MapSet.to_list(), + member_fields: Enum.map(member_fields_visible, &Atom.to_string/1), + custom_field_ids: visible_custom_field_ids, + query: socket.assigns[:query] || nil, + sort_field: export_sort_field(socket.assigns[:sort_field]), + sort_order: export_sort_order(socket.assigns[:sort_order]) + } + end + + 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 end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 394db2c..79017a9 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" 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