249 lines
8.4 KiB
Elixir
249 lines
8.4 KiB
Elixir
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.
|
|
"""
|
|
|
|
alias MvWeb.MemberLive.Index
|
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
|
|
|
@typedoc "Validated export parameters produced by `parse_params/1`."
|
|
@type parsed_params :: %{
|
|
selected_ids: [String.t()],
|
|
member_fields: [String.t()],
|
|
selectable_member_fields: [String.t()],
|
|
computed_fields: [String.t()],
|
|
custom_field_ids: [String.t()],
|
|
query: String.t() | nil,
|
|
sort_field: String.t() | nil,
|
|
sort_order: String.t() | nil,
|
|
show_current_cycle: boolean(),
|
|
cycle_status_filter: :paid | :unpaid | nil,
|
|
boolean_filters: %{optional(String.t()) => boolean()}
|
|
}
|
|
|
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
|
["membership_fee_type", "membership_fee_status", "groups"]
|
|
@computed_export_fields ["membership_fee_status"]
|
|
@computed_insert_after "membership_fee_start_date"
|
|
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
|
|
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()) :: parsed_params()
|
|
def parse_params(params) do
|
|
# DB fields come from "member_fields"
|
|
raw_member_fields = extract_list(params, "member_fields")
|
|
member_fields = filter_allowed_member_fields(raw_member_fields)
|
|
|
|
# computed fields can come from "computed_fields" (new payload) OR legacy inclusion in member_fields
|
|
computed_fields =
|
|
(extract_list(params, "computed_fields") ++ member_fields)
|
|
|> normalize_computed_fields()
|
|
|> Enum.filter(&(&1 in @computed_export_fields))
|
|
|> Enum.uniq()
|
|
|
|
# selectable DB fields: only real domain member fields, ordered like the table
|
|
selectable_member_fields =
|
|
member_fields
|
|
|> Enum.filter(&(&1 in @domain_member_field_strings))
|
|
|> order_member_fields_like_table()
|
|
|
|
# Separate groups from other fields (groups is handled as a special field, not a member field)
|
|
groups_field = if "groups" in member_fields, do: ["groups"], else: []
|
|
|
|
# final member_fields list (used for column specs order): table order + fee type + computed + groups
|
|
ordered_member_fields =
|
|
selectable_member_fields
|
|
|> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields)
|
|
|> then(fn fields -> fields ++ groups_field end)
|
|
|
|
%{
|
|
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
|
member_fields: ordered_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 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) and match?({:ok, _}, Ecto.UUID.cast(k))
|
|
end)
|
|
|> Enum.into(%{})
|
|
|
|
_ ->
|
|
%{}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Applies export filters (cycle status and boolean custom field filters) when exporting "all" (no selected_ids).
|
|
|
|
Used by the CSV export controller so that "Export (all)" with active filters exports only the filtered members,
|
|
matching PDF export behavior.
|
|
|
|
- `members` - Loaded members (must have cycle data loaded when cycle_status_filter is used).
|
|
- `opts` - Map with `:selected_ids`, `:cycle_status_filter`, `:show_current_cycle`, `:boolean_filters`.
|
|
- `custom_fields_by_id` - Map of custom field id => custom field struct (for boolean filter resolution).
|
|
|
|
When `opts.selected_ids` is not empty, returns `members` unchanged (selected_ids
|
|
override filters). Otherwise applies cycle status filter and boolean custom field filters.
|
|
|
|
Uses `Map.get(opts, :selected_ids, [])` so that `nil` or a missing key is treated as
|
|
"export all" and filters are applied.
|
|
"""
|
|
@spec apply_export_filters([struct()], map(), map()) :: [struct()]
|
|
def apply_export_filters(members, opts, custom_fields_by_id) do
|
|
selected_ids = Map.get(opts, :selected_ids, [])
|
|
|
|
if Enum.empty?(selected_ids) do
|
|
members
|
|
|> apply_cycle_status_filter(opts[:cycle_status_filter], opts[:show_current_cycle])
|
|
|> Index.apply_boolean_custom_field_filters(
|
|
Map.get(opts, :boolean_filters, %{}),
|
|
Map.values(custom_fields_by_id)
|
|
)
|
|
else
|
|
members
|
|
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
|
|
|
|
defp order_member_fields_like_table(fields) when is_list(fields) do
|
|
table_order = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
table_order |> Enum.filter(&(&1 in fields))
|
|
end
|
|
|
|
defp insert_fee_type_and_computed_fields_like_table(
|
|
db_fields_ordered,
|
|
computed_fields,
|
|
member_fields
|
|
) do
|
|
db_with_insert =
|
|
Enum.flat_map(db_fields_ordered, fn f ->
|
|
expand_field_with_computed(f, member_fields, computed_fields)
|
|
end)
|
|
|
|
# If fee type is visible but start_date was not in the list, it won't be in db_with_insert
|
|
db_with_insert =
|
|
if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do
|
|
db_with_insert ++ ["membership_fee_type"]
|
|
else
|
|
db_with_insert
|
|
end
|
|
|
|
remaining = Enum.reject(computed_fields, &(&1 in db_with_insert))
|
|
db_with_insert ++ remaining
|
|
end
|
|
|
|
# Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order).
|
|
defp expand_field_with_computed(f, member_fields, computed_fields) do
|
|
if f == @computed_insert_after do
|
|
extra = []
|
|
|
|
extra =
|
|
if "membership_fee_type" in member_fields,
|
|
do: extra ++ ["membership_fee_type"],
|
|
else: extra
|
|
|
|
extra =
|
|
if "membership_fee_status" in computed_fields,
|
|
do: extra ++ ["membership_fee_status"],
|
|
else: extra
|
|
|
|
[f] ++ extra
|
|
else
|
|
[f]
|
|
end
|
|
end
|
|
|
|
defp normalize_computed_fields(fields) when is_list(fields) do
|
|
fields
|
|
|> Enum.filter(&is_binary/1)
|
|
|> Enum.map(fn
|
|
"payment_status" -> "membership_fee_status"
|
|
other -> other
|
|
end)
|
|
end
|
|
end
|