defmodule Mv.Membership.MembersCSV do @moduledoc """ Exports members to CSV (RFC 4180) as iodata. Uses a column-based API: `export(members, columns)` where each column has `header` (display string, e.g. from Web layer), `kind` (:member_field | :custom_field | :computed), and `key` (member attribute name, custom_field id, or computed key). Custom field columns include a `custom_field` struct for value formatting. Domain code does not use Gettext; headers and computed values come from the caller (e.g. controller). """ alias Mv.Membership.CustomFieldValueFormatter alias NimbleCSV.RFC4180 @doc """ Exports a list of members to CSV iodata. - `members` - List of member structs or maps (with optional `custom_field_values` loaded) - `columns` - List of column specs: `%{header: String.t(), kind: :member_field | :custom_field | :computed, key: term()}` For `:custom_field`, also pass `custom_field: %CustomField{}`. Header is used as-is (localized by caller). Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body. RFC 4180 escaping and formula-injection safe_cell are applied. """ @spec export([struct() | map()], [map()]) :: iodata() def export(members, columns) when is_list(members) do header = build_header(columns) rows = Enum.map(members, fn member -> build_row(member, columns) end) RFC4180.dump_to_iodata([header | rows]) end defp build_header(columns) do columns |> Enum.map(fn col -> col.header end) |> Enum.map(&safe_cell/1) end defp build_row(member, columns) do columns |> Enum.map(fn col -> cell_value(member, col) end) |> Enum.map(&safe_cell/1) end defp cell_value(member, %{kind: :member_field, key: key}) 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}) do cfv = get_cfv_by_id(member, id) if cfv, do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf), else: "" end defp cell_value(member, %{kind: :computed, key: key}) do value = Map.get(member, key_to_atom(key)) if is_binary(value), do: value, else: "" 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 @doc false @spec safe_cell(String.t()) :: String.t() def safe_cell(s) when is_binary(s) do if String.starts_with?(s, ["=", "+", "-", "@", "\t"]), do: "'" <> s, else: s 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) end