defmodule Mv.Membership.MembersCSV do @moduledoc """ Exports members to CSV (RFC 4180) as iodata. Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings; custom field values use the same formatting logic as the member overview (neutral formatter). Column order for custom fields follows the key order of the `custom_fields_by_id` map. """ alias Mv.Membership.CustomFieldValueFormatter alias NimbleCSV.RFC4180 @doc """ Exports a list of members to CSV iodata. - `members` - List of member structs (with optional `custom_field_values` loaded) - `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`) - `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order. Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body. """ @spec export( [struct()], [String.t()], %{optional(String.t() | Ecto.UUID.t()) => struct()} ) :: iodata() def export(members, member_fields, custom_fields_by_id) when is_list(members) do custom_entries = custom_field_entries(custom_fields_by_id) header = build_header(member_fields, custom_entries) rows = Enum.map(members, &build_row(&1, member_fields, custom_entries)) RFC4180.dump_to_iodata([header | rows]) end defp custom_field_entries(by_id) when is_map(by_id) do Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end) end defp build_header(member_fields, custom_entries) do member_headers = member_fields custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end) member_headers ++ custom_headers end defp build_row(member, member_fields, custom_entries) do member_cells = Enum.map(member_fields, &format_member_field(member, &1)) custom_cells = Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end) member_cells ++ custom_cells end defp format_member_field(member, field_name) do key = member_field_key(field_name) value = Map.get(member, key) format_member_value(value) end defp member_field_key(field_name) when is_binary(field_name) do try do String.to_existing_atom(field_name) rescue ArgumentError -> field_name 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_custom_field(member, custom_field_id, custom_field) do cfv = find_custom_field_value(member, custom_field_id) if cfv, do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field), else: "" end defp find_custom_field_value(member, custom_field_id) do values = Map.get(member, :custom_field_values) || [] id_str = to_string(custom_field_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 end