defmodule Mv.Membership.MemberExport.Build do @moduledoc """ Builds export data structure for member exports (CSV/PDF). Extracts common logic for loading, filtering, sorting, and formatting member data into a unified structure that can be used by both CSV and PDF exporters. Returns a structure: ``` %{ columns: [%{key: term(), kind: :member_field | :custom_field | :computed, ...}], rows: [[cell_string, ...]], meta: %{generated_at: String.t(), member_count: integer(), ...} } ``` No translations/Gettext in this module - labels come from the web layer via a function. """ require Ash.Query import Ash.Expr alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort} alias MvWeb.MemberLive.Index.MembershipFeeStatus @custom_field_prefix Mv.Constants.custom_field_prefix() @doc """ Builds export data structure from parsed parameters. - `actor` - Ash actor (e.g. current user) - `parsed` - Map with export parameters (from `MemberExport.parse_params/1`) - `label_fn` - Function to get labels for columns: `(key) -> String.t()` Returns `{:ok, data}` or `{:error, :forbidden}`. The `data` map contains: - `columns`: List of column specs with `key`, `kind`, and optional `custom_field` - `rows`: List of rows, each row is a list of cell strings - `meta`: Metadata including `generated_at` and `member_count` """ @spec build(struct(), map(), (term() -> String.t())) :: {:ok, map()} | {:error, :forbidden} def build(actor, parsed, label_fn) when is_function(label_fn, 1) do # Ensure sort custom field is loaded if needed parsed = ensure_sort_custom_field_loaded(parsed) 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 columns = build_columns(parsed, custom_fields_by_id, label_fn) rows = build_rows(members, columns, custom_fields_by_id) meta = build_meta(members) {:ok, %{columns: columns, rows: rows, meta: meta}} 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]) 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(actor, parsed, custom_fields_by_id) do {query, sort_after_load} = 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, sort_after_load) {: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 need_groups = "groups" in parsed.member_fields need_membership_fee_type = "membership_fee_type" in parsed.member_fields or parsed.sort_field == "membership_fee_type" 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) |> maybe_load_groups(need_groups) |> maybe_load_membership_fee_type(need_membership_fee_type) query = if parsed.selected_ids != [] do Ash.Query.filter(query, expr(id in ^parsed.selected_ids)) else apply_search(query, parsed.query) end # Apply sorting at query level if possible (not custom fields) maybe_sort(query, parsed.sort_field, parsed.sort_order) end defp process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) do members |> apply_post_load_filters(parsed, custom_fields_by_id) |> apply_post_load_sorting(parsed, custom_fields_by_id, sort_after_load) |> 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) |> MvWeb.MemberLive.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, sort_after_load) do # Sort after load for custom fields (always, even with selected_ids) if sort_after_load do sort_members_by_custom_field( members, parsed.sort_field, parsed.sort_order, Map.values(custom_fields_by_id) ) else # For selected_ids, we may need to apply sorting that wasn't done at query level if (parsed.selected_ids != [] and parsed.sort_field) && parsed.sort_order do # Re-sort in memory to ensure consistent ordering sort_members_in_memory(members, parsed.sort_field, parsed.sort_order) else members end end end defp sort_members_in_memory(members, field, order) when is_binary(field) do field_atom = String.to_existing_atom(field) if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do key_fn = sort_key_fn_for_field(field_atom) compare_fn = build_compare_fn(order) Enum.sort_by(members, key_fn, compare_fn) else members end rescue ArgumentError -> members end defp sort_members_in_memory(members, _field, _order), do: members defp sort_key_fn_for_field(:membership_fee_type) do fn member -> case Map.get(member, :membership_fee_type) do nil -> nil rel -> Map.get(rel, :name) end end end defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end defp build_compare_fn("asc"), do: fn a, b -> a <= b end defp build_compare_fn("desc"), do: fn a, b -> b <= a end defp build_compare_fn(_), do: fn _a, _b -> true 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 cond do field == "groups" -> {query, true} field == "membership_fee_type" -> apply_fee_type_sort(query, order) custom_field_sort?(field) -> {query, true} true -> apply_standard_member_sort(query, field, order) end rescue ArgumentError -> {query, false} end defp apply_fee_type_sort(query, order) do order_atom = if order == "desc", do: :desc, else: :asc {Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false} end defp apply_standard_member_sort(query, field, order) do field_atom = String.to_existing_atom(field) sortable = field_atom in (Mv.Constants.member_fields() -- [:notes]) or field_atom == :membership_fee_type if sortable do order_atom = if order == "desc", do: :desc, else: :asc sort_field = if field_atom == :membership_fee_type, do: {"membership_fee_type.name", order_atom}, else: {field_atom, order_atom} {Ash.Query.sort(query, [sort_field]), false} else {query, false} end end 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 if field == "groups" do sort_members_by_groups_export(members, order) else sort_by_custom_field_value(members, field, order, custom_fields) end end defp sort_by_custom_field_value(members, field, order, custom_fields) 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 else sort_members_with_custom_field(members, custom_field, order) end end defp sort_members_with_custom_field(members, custom_field, order) do 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 sort_members_by_groups_export(members, order) do # Members with groups first, then by first group name alphabetically (min = first by sort order) # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 first_group_name = fn member -> (member.groups || []) |> Enum.map(& &1.name) |> Enum.min(fn -> nil end) end members |> Enum.sort_by(fn member -> name = first_group_name.(member) # Nil (no groups) sorts last in asc, first in desc {name == nil, name || ""} end) |> then(fn list -> if order == "desc", do: Enum.reverse(list), else: list 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 maybe_load_groups(query, false), do: query defp maybe_load_groups(query, true) do # Load groups with id and name only (for export formatting) Ash.Query.load(query, groups: [:id, :name]) end defp maybe_load_membership_fee_type(query, false), do: query defp maybe_load_membership_fee_type(query, true) do Ash.Query.load(query, membership_fee_type: [:id, :name]) 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 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) # Format as string for export (controller will handle translation) status_string = format_membership_fee_status(status) Map.put(member, :membership_fee_status, status_string) end) else members end end defp format_membership_fee_status(:paid), do: "paid" defp format_membership_fee_status(:unpaid), do: "unpaid" defp format_membership_fee_status(:suspended), do: "suspended" defp format_membership_fee_status(nil), do: "" defp build_columns(parsed, custom_fields_by_id, label_fn) do member_cols = Enum.map(parsed.selectable_member_fields, fn field -> %{ key: field, kind: :member_field, label: label_fn.(field) } end) computed_cols = Enum.map(parsed.computed_fields, fn key -> atom_key = String.to_existing_atom(key) %{ key: atom_key, kind: :computed, label: label_fn.(atom_key) } end) membership_fee_type_col = if "membership_fee_type" in parsed.member_fields do [ %{ key: :membership_fee_type, kind: :membership_fee_type, label: label_fn.(:membership_fee_type) } ] else [] end groups_col = if "groups" in parsed.member_fields do [ %{ key: :groups, kind: :groups, label: label_fn.(:groups) } ] else [] 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 %{ key: to_string(id), kind: :custom_field, label: cf.name, custom_field: cf } else nil end end) |> Enum.reject(&is_nil/1) # Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do Enum.map(members, fn member -> Enum.map(columns, fn col -> cell_value(member, col, custom_fields_by_id) end) end) end defp cell_value(member, %{kind: :member_field, key: key}, _custom_fields_by_id) 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}, _custom_fields_by_id) do cfv = get_cfv_by_id(member, id) if cfv do CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf) else "" end end defp cell_value(member, %{kind: :computed, key: key}, _custom_fields_by_id) do value = Map.get(member, key) if is_binary(value), do: value, else: "" end defp cell_value( member, %{kind: :membership_fee_type, key: :membership_fee_type}, _custom_fields_by_id ) do case Map.get(member, :membership_fee_type) do %{name: name} when is_binary(name) -> name _ -> "" end end defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do groups = Map.get(member, :groups) || [] format_groups(groups) 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 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_groups([]), do: "" defp format_groups(groups) when is_list(groups) do groups |> Enum.map(fn group -> Map.get(group, :name) || "" end) |> Enum.reject(&(&1 == "")) |> Enum.join(", ") end defp build_meta(members) do %{ generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), member_count: length(members) } end end