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