Use Ash related-field sort (membership_fee_type.name) instead of membership_fee_type_id so column order is alphabetical. Load membership_fee_type when sorting by it even if column is hidden. In-memory re-sort (Build) uses loaded fee type name. Co-authored-by: Cursor <cursoragent@cursor.com>
567 lines
17 KiB
Elixir
567 lines
17 KiB
Elixir
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
|