defmodule Mv.Membership.Import.ColumnResolver do @moduledoc """ Read-only resolution of CSV import columns against the database. Given the `HeaderMapper.build_maps/2` result, the raw numbered rows, and an actor, `resolve/3` determines: - which group names in the groups column already exist (`groups_found`) and which would have to be created (`groups_to_create`); - a small set of preview rows for the mapping preview UI. No database writes happen here; the resolver only reads. Group creation and member-group assignment happen during processing via `create_or_find_group/3`. This module has no Phoenix or web dependencies. """ require Logger alias Mv.Membership.Import.HeaderMapper @preview_row_limit 3 @type numbered_row :: {pos_integer(), [String.t()]} @type resolution :: %{ groups_found: [%{id: String.t(), name: String.t()}], groups_to_create: [String.t()], fee_type_map: %{String.t() => String.t()}, fee_type_warnings: [String.t()], has_empty_fee_type_cells?: boolean(), preview_rows: [[String.t()]] } @doc """ Resolves the group and fee-type columns of an import against the database and extracts preview rows. Returns a map with `:groups_found`, `:groups_to_create`, `:fee_type_map`, `:fee_type_warnings`, `:has_empty_fee_type_cells?`, and `:preview_rows`. """ @spec resolve(map(), [numbered_row()], term()) :: resolution() def resolve(header_maps, rows, actor) do %{ groups_found: groups_found, groups_to_create: groups_to_create } = resolve_groups(header_maps, rows, actor) %{ fee_type_map: fee_type_map, fee_type_warnings: fee_type_warnings, has_empty_fee_type_cells?: has_empty_fee_type_cells? } = resolve_fee_types(header_maps, rows, actor) %{ groups_found: groups_found, groups_to_create: groups_to_create, fee_type_map: fee_type_map, fee_type_warnings: fee_type_warnings, has_empty_fee_type_cells?: has_empty_fee_type_cells?, preview_rows: preview_rows(rows) } end defp resolve_groups(%{groups_column_index: nil}, _rows, _actor) do %{groups_found: [], groups_to_create: []} end defp resolve_groups(%{groups_column_index: index}, rows, actor) do existing_groups = list_groups(actor) lookup = build_group_lookup(existing_groups) names = unique_group_names(rows, index) {found, to_create} = Enum.reduce(names, {[], []}, fn name, {found, to_create} -> case Map.get(lookup, normalize_name(name)) do nil -> {found, [name | to_create]} group -> {[%{id: group.id, name: group.name} | found], to_create} end end) %{groups_found: Enum.reverse(found), groups_to_create: Enum.reverse(to_create)} end defp resolve_fee_types(%{fee_type_column_index: nil}, _rows, _actor) do %{fee_type_map: %{}, fee_type_warnings: [], has_empty_fee_type_cells?: false} end defp resolve_fee_types(%{fee_type_column_index: index}, rows, actor) do lookup = build_fee_type_lookup(actor) cells = Enum.map(rows, fn {_line, values} -> Enum.at(values, index) end) has_empty? = Enum.any?(cells, &blank?/1) {fee_type_map, warnings} = cells |> Enum.reject(&blank?/1) |> Enum.uniq_by(&normalize_fee_type_name/1) |> Enum.reduce({%{}, []}, fn name, {map, warnings} -> case Map.get(lookup, normalize_fee_type_name(name)) do nil -> {map, [String.trim(name) | warnings]} id -> {Map.put(map, normalize_fee_type_name(name), id), warnings} end end) %{ fee_type_map: fee_type_map, fee_type_warnings: Enum.reverse(warnings), has_empty_fee_type_cells?: has_empty? } end @doc """ Normalizes a fee-type name using the same rules as CSV header normalization (trim, lowercase, transliterate, drop hyphens and whitespace). """ @spec normalize_fee_type_name(String.t() | nil) :: String.t() def normalize_fee_type_name(name) when is_binary(name), do: HeaderMapper.normalize_header(name) def normalize_fee_type_name(_), do: "" defp build_fee_type_lookup(actor) do actor |> list_fee_types() |> Enum.reduce(%{}, fn fee_type, acc -> normalized = normalize_fee_type_name(fee_type.name) if Map.has_key?(acc, normalized) do Logger.warning( "Multiple membership fee types normalize to #{inspect(normalized)}; using the first match for CSV import." ) acc else Map.put(acc, normalized, fee_type.id) end end) end defp list_fee_types(actor) do Mv.MembershipFees.list_membership_fee_types!(actor: actor) end defp blank?(nil), do: true defp blank?(value) when is_binary(value), do: String.trim(value) == "" defp blank?(_), do: false @doc """ Finds an existing group by name (case-insensitive) or creates it. Looks first in the pre-fetched `groups` list, then in the database (to catch groups created earlier in the same import), and only creates a new group when none is found. This keeps group resolution idempotent across re-imports. """ @spec create_or_find_group(String.t(), [Mv.Membership.Group.t()], term()) :: {:ok, Mv.Membership.Group.t()} | {:error, term()} def create_or_find_group(name, groups, actor) when is_binary(name) do trimmed = String.trim(name) normalized = normalize_name(trimmed) case find_group_in_list(groups, normalized) do nil -> find_or_create_group(trimmed, normalized, actor) group -> {:ok, group} end end defp find_group_in_list(groups, normalized) do Enum.find(groups, fn group -> normalize_name(group.name) == normalized end) end defp find_or_create_group(trimmed, normalized, actor) do case fetch_group_by_normalized_name(normalized, actor) do nil -> create_group(trimmed, normalized, actor) group -> {:ok, group} end end # Normalizes the Ash code-interface return to a two-shape result. # # On a create failure the group may have been created concurrently by another # import session between our read and our write (the DB unique index is the # final arbiter, and the name validation is fail-open). Re-fetch by normalized # name and link to the existing group rather than failing the row. defp create_group(name, normalized, actor) do case Mv.Membership.create_group(%{name: name}, actor: actor) do {:ok, %Mv.Membership.Group{} = group} -> {:ok, group} {:error, reason} -> case fetch_group_by_normalized_name(normalized, actor) do nil -> {:error, reason} group -> {:ok, group} end end end # Fetches a single group by case-insensitive name using a name-filtered query # rather than reading the whole groups table. `normalized` is the trimmed, # lower-cased name; the DB comparison uses LOWER(name) consistent with the # Group resource's case-insensitive uniqueness constraint. defp fetch_group_by_normalized_name(normalized, actor) do require Ash.Query Mv.Membership.Group |> Ash.Query.filter(fragment("LOWER(?) = ?", name, ^normalized)) |> Ash.read(actor: actor, domain: Mv.Membership) |> case do {:ok, [group | _]} -> group _ -> nil end end @doc """ Splits a raw groups-cell value into trimmed, non-empty group names. """ @spec split_group_names(String.t() | nil) :: [String.t()] def split_group_names(nil), do: [] def split_group_names(cell) when is_binary(cell) do cell |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) end defp unique_group_names(rows, index) do rows |> Enum.flat_map(fn {_line, values} -> values |> Enum.at(index) |> split_group_names() end) |> Enum.uniq_by(&normalize_name/1) end defp preview_rows(rows) do rows |> Enum.take(@preview_row_limit) |> Enum.map(fn {_line, values} -> values end) end defp list_groups(actor) do Mv.Membership.list_groups!(actor: actor) end defp build_group_lookup(groups) do Enum.reduce(groups, %{}, fn group, acc -> Map.put(acc, normalize_name(group.name), group) end) end # Case-insensitive comparison consistent with the Group resource's # case-insensitive name uniqueness. defp normalize_name(name) when is_binary(name) do name |> String.trim() |> String.downcase() end end