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