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