diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index a98b125..0e6793d 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -7,12 +7,6 @@ defmodule Mv.Membership.MemberExport do 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 alias MvWeb.MemberLive.Index.MembershipFeeStatus @@ -35,261 +29,8 @@ defmodule Mv.Membership.MemberExport do ["membership_fee_type", "membership_fee_status", "groups"] @computed_export_fields ["membership_fee_status"] @computed_insert_after "membership_fee_start_date" - @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 = 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 build_column_specs(parsed, custom_fields_by_id) do - member_specs = build_member_column_specs(parsed) - custom_specs = build_custom_column_specs(parsed, custom_fields_by_id) - - member_specs ++ custom_specs - end - - defp build_member_column_specs(parsed) do - Enum.map(parsed.member_fields, fn f -> - build_single_member_spec(f, parsed.selectable_member_fields) - end) - |> Enum.reject(&is_nil/1) - end - - defp build_single_member_spec(field, selectable_member_fields) do - if field in selectable_member_fields do - %{kind: :member_field, key: field} - else - build_computed_spec(field) - end - end - - defp build_computed_spec(field) do - # only allow known computed export fields to avoid crashing on unknown atoms - if field in @computed_export_fields do - %{kind: :computed, key: String.to_existing_atom(field)} - else - # ignore unknown non-selectable fields defensively - nil - end - end - - defp build_custom_column_specs(parsed, custom_fields_by_id) do - 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) - end - - defp load_members(actor, parsed, custom_fields_by_id) do - query = build_members_query(parsed, custom_fields_by_id) - - case Ash.read(query, actor: actor) do - {:ok, members} -> - processed_members = process_loaded_members(members, parsed, custom_fields_by_id) - {:ok, processed_members} - - {:error, %Ash.Error.Forbidden{}} -> - {:error, :forbidden} - end - end - - defp build_members_query(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 != [] or - "membership_fee_status" in parsed.member_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) - - 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 - end - - defp process_loaded_members(members, parsed, custom_fields_by_id) do - members - |> apply_post_load_filters(parsed, custom_fields_by_id) - |> apply_post_load_sorting(parsed, custom_fields_by_id) - |> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle) - end - - defp apply_post_load_filters(members, parsed, custom_fields_by_id) do - if parsed.selected_ids == [] do - members - |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) - |> Index.apply_boolean_custom_field_filters( - parsed.boolean_filters || %{}, - Map.values(custom_fields_by_id) - ) - else - members - end - end - - defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do - 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 - 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 @@ -298,20 +39,6 @@ defmodule Mv.Membership.MemberExport do defp apply_cycle_status_filter(members, _status, _show_current), do: members - defp add_computed_fields(members, computed_fields, show_current_cycle) do - computed_fields = computed_fields || [] - - if "membership_fee_status" in computed_fields do - Enum.map(members, fn member -> - status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle) - # <= Atom rein - Map.put(member, :membership_fee_status, status) - end) - else - members - end - end - # 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).