mitgliederverwaltung/lib/mv/membership/member_export.ex

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