defmodule MvWeb.MemberLive.Index do @moduledoc """ LiveView for displaying and managing the member list. ## Features - Full-text search across member profiles using PostgreSQL tsvector - Sortable columns (name, email, address fields) - Bulk selection for future batch operations - Real-time updates via LiveView - Bookmarkable URLs with query parameters ## URL Parameters - `query` - Search query string for full-text search - `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date) - `sort_order` - Sort direction (:asc or :desc) ## Events - `delete` - Remove a member from the database - `select_member` - Toggle individual member selection - `select_all` - Toggle selection of all visible members - `copy_emails` - Copy email addresses of selected members to clipboard ## Implementation Notes - Search uses PostgreSQL full-text search (plainto_tsquery) - Sort state is synced with URL for bookmarkability - Components communicate via `handle_info` for decoupling """ use MvWeb, :live_view require Ash.Query require Logger import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() # Maximum number of boolean custom field filters allowed per request (DoS protection) @max_boolean_filters Mv.Constants.max_boolean_filters() # Maximum length of UUID string (36 characters including hyphens) @max_uuid_length Mv.Constants.max_uuid_length() # Member fields that are loaded for the overview # Uses constants from Mv.Constants to ensure consistency # Note: :id is always included for member identification # All member fields are loaded, but visibility is controlled via settings @overview_fields [:id | Mv.Constants.member_fields()] @doc """ Initializes the LiveView state. Sets up initial assigns for page title, search query, sort configuration, payment filter, and member selection. Actual data loading happens in `handle_params/3`. """ @impl true def mount(_params, session, socket) do # Load custom fields that should be shown in overview (for display) # Errors in mount are handled by Phoenix LiveView and result in a 500 error page. # This is appropriate for initialization errors that should be visible to the user. actor = current_actor(socket) custom_fields_visible = Mv.Membership.CustomField |> Ash.Query.filter(expr(show_in_overview == true)) |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) # Load ALL custom fields for the dropdown (to show all available fields) all_custom_fields = Mv.Membership.CustomField |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) # Load boolean custom fields (filtered and sorted from all_custom_fields) boolean_custom_fields = all_custom_fields |> Enum.filter(&(&1.value_type == :boolean)) |> Enum.sort_by(& &1.name, :asc) # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do {:ok, s} -> s # Fallback if settings can't be loaded {:error, _} -> %{member_field_visibility: %{}} end # Load user field selection from session session_selection = FieldSelection.get_from_session(session) # FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice) all_available_fields = all_custom_fields |> FieldVisibility.get_all_available_fields() |> dedupe_available_fields() initial_selection = FieldVisibility.merge_with_global_settings( session_selection, settings, all_custom_fields ) socket = socket |> assign(:page_title, gettext("Members")) |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:cycle_status_filter, nil) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) |> assign(:all_custom_fields, all_custom_fields) |> assign(:boolean_custom_fields, boolean_custom_fields) |> assign(:all_available_fields, all_available_fields) |> assign(:user_field_selection, initial_selection) |> assign( :member_fields_visible, FieldVisibility.get_visible_member_fields(initial_selection) ) |> assign( :member_fields_visible_db, FieldVisibility.get_visible_member_fields_db(initial_selection) ) |> assign( :member_fields_visible_computed, FieldVisibility.get_visible_member_fields_computed(initial_selection) ) |> assign(:show_current_cycle, false) |> assign(:membership_fee_status_filter, nil) |> assign_export_payload() {:ok, socket} end # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @doc """ Handles member-related UI events. ## Supported events: - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members """ @impl true def handle_event("delete", %{"id" => id}, socket) do actor = current_actor(socket) case Ash.get(Mv.Membership.Member, id, actor: actor) do {:ok, member} -> case Ash.destroy(member, actor: actor) do :ok -> updated_members = Enum.reject(socket.assigns.members, &(&1.id == id)) {:noreply, socket |> assign(:members, updated_members) |> put_flash(:info, gettext("Member deleted successfully"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, put_flash( socket, :error, gettext("You do not have permission to delete this member") )} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} end {:error, %Ash.Error.Query.NotFound{}} -> {:noreply, put_flash(socket, :error, gettext("Member not found"))} {:error, %Ash.Error.Forbidden{} = _error} -> {:noreply, put_flash(socket, :error, gettext("You do not have permission to access this member"))} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} end end @impl true def handle_event("select_member", %{"id" => id}, socket) do selected = if MapSet.member?(socket.assigns.selected_members, id) do MapSet.delete(socket.assigns.selected_members, id) else MapSet.put(socket.assigns.selected_members, id) end {:noreply, socket |> assign(:selected_members, selected) |> update_selection_assigns()} end @impl true def handle_event("select_all", _params, socket) do all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new() selected = if MapSet.equal?(socket.assigns.selected_members, all_ids) do MapSet.new() else all_ids end {:noreply, socket |> assign(:selected_members, selected) |> update_selection_assigns()} end @impl true def handle_event("toggle_cycle_view", _params, socket) do new_show_current = !socket.assigns.show_current_cycle socket = socket |> assign(:show_current_cycle, new_show_current) |> load_members() |> update_selection_assigns() query_params = build_query_params( socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, new_show_current, socket.assigns.boolean_custom_field_filters ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} end @impl true def handle_event("copy_emails", _params, socket) do selected_ids = socket.assigns.selected_members # Filter members that are in the selection and have email addresses formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids) email_count = length(formatted_emails) cond do MapSet.size(selected_ids) == 0 -> {:noreply, put_flash(socket, :error, gettext("No members selected"))} email_count == 0 -> {:noreply, put_flash(socket, :error, gettext("No email addresses found"))} true -> # RFC 5322 uses comma as separator for email address lists email_string = Enum.join(formatted_emails, ", ") socket = socket |> push_event("copy_to_clipboard", %{text: email_string}) |> put_flash( :success, ngettext( "Copied %{count} email address to clipboard", "Copied %{count} email addresses to clipboard", email_count, count: email_count ) ) |> put_flash( :warning, gettext("Tip: Paste email addresses into the BCC field for privacy compliance") ) {:noreply, socket} end end # Helper to format errors for display defp format_error(%Ash.Error.Invalid{errors: errors}) do error_messages = Enum.map(errors, fn error -> case error do %{field: field, message: message} -> "#{field}: #{message}" %{message: message} -> message _ -> inspect(error) end end) Enum.join(error_messages, ", ") end defp format_error(error), do: inspect(error) # ----------------------------------------------------------------- # Handle Infos from Child Components # ----------------------------------------------------------------- @doc """ Handles messages from child components. ## Supported messages: - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ @impl true def handle_info({:sort, field_str}, socket) do # Handle both atom and string field names (for custom fields) field = try do String.to_existing_atom(field_str) rescue ArgumentError -> field_str end {new_field, new_order} = determine_new_sort(field, socket) old_field = socket.assigns.sort_field socket = socket |> assign(:sort_field, new_field) |> assign(:sort_order, new_order) |> update_sort_components(old_field, new_field, new_order) |> load_members() |> update_selection_assigns() # URL sync query_params = build_query_params( socket.assigns.query, export_sort_field(socket.assigns.sort_field), export_sort_order(socket.assigns.sort_order), socket.assigns.cycle_status_filter, socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) |> maybe_add_field_selection(socket.assigns[:user_field_selection]) {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} end @impl true def handle_info({:search_changed, q}, socket) do socket = socket |> assign(:query, q) |> load_members() |> update_selection_assigns() query_params = build_query_params( q, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} end @impl true def handle_info({:payment_filter_changed, filter}, socket) do socket = socket |> assign(:cycle_status_filter, filter) |> load_members() |> update_selection_assigns() query_params = build_query_params( socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, filter, socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} end @impl true def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do updated_filters = if filter_value == nil do Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str) else Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value) end socket = socket |> assign(:boolean_custom_field_filters, updated_filters) |> load_members() |> update_selection_assigns() query_params = build_query_params( socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, socket.assigns.show_current_cycle, updated_filters ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} end @impl true def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do socket = socket |> assign(:cycle_status_filter, cycle_status_filter) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() query_params = build_query_params( socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, cycle_status_filter, socket.assigns.show_current_cycle, boolean_filters ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} end @impl true def handle_info({:field_toggled, field_string, visible}, socket) do new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) socket = update_session_field_selection(socket, new_selection) final_selection = FieldVisibility.merge_with_global_settings( new_selection, socket.assigns.settings, socket.assigns.all_custom_fields ) visible_member_fields = final_selection |> FieldVisibility.get_visible_member_fields() |> Enum.uniq() visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) visible_member_fields_computed = FieldVisibility.get_visible_member_fields_computed(final_selection) visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() {:noreply, socket} end @impl true def handle_info({:fields_selected, selection}, socket) do socket = update_session_field_selection(socket, selection) final_selection = FieldVisibility.merge_with_global_settings( selection, socket.assigns.settings, socket.assigns.all_custom_fields ) visible_member_fields = final_selection |> FieldVisibility.get_visible_member_fields() |> Enum.uniq() visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) visible_member_fields_computed = FieldVisibility.get_visible_member_fields_computed(final_selection) visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() {:noreply, socket} end # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @impl true def handle_params(params, _url, socket) do prev_sig = build_signature(socket) url_selection = FieldSelection.parse_from_url(params) merged_selection = FieldSelection.merge_sources( url_selection, socket.assigns.user_field_selection, %{} ) final_selection = FieldVisibility.merge_with_global_settings( merged_selection, socket.assigns.settings, socket.assigns.all_custom_fields ) visible_member_fields = final_selection |> FieldVisibility.get_visible_member_fields() |> Enum.uniq() visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) visible_member_fields_computed = FieldVisibility.get_visible_member_fields_computed(final_selection) visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_cycle_status_filter(params) |> maybe_update_boolean_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:query, params["query"]) |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) next_sig = build_signature(socket) socket = if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do socket |> prepare_dynamic_cols() |> update_selection_assigns() else socket |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() end {:noreply, socket} end defp build_signature(socket) do { socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, socket.assigns[:visible_custom_field_ids] || [] } end defp prepare_dynamic_cols(socket) do visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] visible_set = MapSet.new(visible_custom_field_ids) dynamic_cols = socket.assigns.all_custom_fields |> Enum.filter(fn custom_field -> MapSet.member?(visible_set, to_string(custom_field.id)) end) |> Enum.map(fn custom_field -> %{ custom_field: custom_field, render: fn member -> case get_custom_field_value(member, custom_field) do nil -> "" cfv -> Formatter.format_custom_field_value(cfv.value, custom_field) end end } end) assign(socket, :dynamic_cols, dynamic_cols) end # ------------------------------------------------------------- # Sorting # ------------------------------------------------------------- defp determine_new_sort(field, socket) do if socket.assigns.sort_field == field do {field, toggle_order(socket.assigns.sort_order)} else {field, :asc} end end defp update_sort_components(socket, old_field, new_field, new_order) do active_id = to_sort_id(new_field) old_id = to_sort_id(old_field) send_update(MvWeb.Components.SortHeaderComponent, id: active_id, sort_field: new_field, sort_order: new_order ) send_update(MvWeb.Components.SortHeaderComponent, id: old_id, sort_field: new_field, sort_order: new_order ) socket end defp to_sort_id(field) when is_binary(field) do try do String.to_existing_atom("sort_#{field}") rescue ArgumentError -> :"sort_#{field}" end end defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}" defp push_sort_url(socket, field, order) do field_str = if is_atom(field) do Atom.to_string(field) else field end query_params = build_query_params( socket.assigns.query, field_str, Atom.to_string(order), socket.assigns.cycle_status_filter, socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} end defp maybe_add_field_selection(params, nil), do: params defp maybe_add_field_selection(params, selection) when is_map(selection) do fields_param = FieldSelection.to_url_param(selection) if fields_param != "", do: Map.put(params, "fields", fields_param), else: params end defp maybe_add_field_selection(params, _), do: params defp push_field_selection_url(socket) do query_params = build_query_params( socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) |> maybe_add_field_selection(socket.assigns[:user_field_selection]) new_path = ~p"/members?#{query_params}" push_patch(socket, to: new_path, replace: true) end defp update_session_field_selection(socket, selection) do assign(socket, :user_field_selection, selection) end defp build_query_params( query, sort_field, sort_order, cycle_status_filter, show_current_cycle, boolean_filters ) do field_str = if is_atom(sort_field) do Atom.to_string(sort_field) else sort_field end order_str = if is_atom(sort_order) do Atom.to_string(sort_order) else sort_order end base_params = %{ "query" => query, "sort_field" => field_str, "sort_order" => order_str } base_params = case cycle_status_filter do nil -> base_params :paid -> Map.put(base_params, "cycle_status_filter", "paid") :unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid") end base_params = if show_current_cycle do Map.put(base_params, "show_current_cycle", "true") else base_params end Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc -> param_key = "#{@boolean_filter_prefix}#{custom_field_id}" param_value = if filter_value == true, do: "true", else: "false" Map.put(acc, param_key, param_value) end) end # ------------------------------------------------------------- # Loading members # ------------------------------------------------------------- defp load_members(socket) do search_query = socket.assigns.query query = Mv.Membership.Member |> Ash.Query.new() |> Ash.Query.select(@overview_fields) visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] boolean_custom_fields_map = socket.assigns.boolean_custom_fields |> Map.new(fn cf -> {to_string(cf.id), cf} end) active_boolean_filter_ids = socket.assigns.boolean_custom_field_filters |> Map.keys() |> Enum.filter(fn id_str -> String.length(id_str) <= @max_uuid_length && match?({:ok, _}, Ecto.UUID.cast(id_str)) && Map.has_key?(boolean_custom_fields_map, id_str) end) ids_to_load = (visible_custom_field_ids ++ active_boolean_filter_ids) |> Enum.uniq() query = load_custom_field_values(query, ids_to_load) query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) query = apply_search_filter(query, search_query) # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields {query, sort_after_load} = maybe_sort( query, socket.assigns.sort_field, socket.assigns.sort_order, custom_fields_for_sort ) # Errors in handle_params are handled by Phoenix LiveView actor = current_actor(socket) {time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end) Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms") # Custom field values are already filtered at the database level in load_custom_field_values/2 # No need for in-memory filtering anymore # Apply cycle status filter if set members = apply_cycle_status_filter( members, socket.assigns.cycle_status_filter, socket.assigns.show_current_cycle ) # Apply boolean custom field filters if set members = apply_boolean_custom_field_filters( members, socket.assigns.boolean_custom_field_filters, socket.assigns.all_custom_fields ) # Sort in memory if needed (custom fields only; computed fields are blocked) members = if sort_after_load and socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do sort_members_in_memory( members, socket.assigns.sort_field, socket.assigns.sort_order, custom_fields_for_sort ) else members end assign(socket, :members, members) end defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do custom_field_values_query = Mv.Membership.CustomFieldValue |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids)) |> Ash.Query.load(custom_field: [:id, :name, :value_type]) query |> Ash.Query.load(custom_field_values: custom_field_values_query) end # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- defp apply_search_filter(query, search_query) do if search_query && String.trim(search_query) != "" do query |> Mv.Membership.Member.fuzzy_search(%{query: search_query}) else query end end 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 toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp toggle_order(nil), do: :asc # Function to sort the column if needed. # Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash. # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory. defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false} defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} defp maybe_sort(query, field, order, _custom_fields) do computed_atoms = FieldVisibility.computed_member_fields() computed_strings = Enum.map(computed_atoms, &Atom.to_string/1) cond do # Block computed fields (atom and string variants) (is_atom(field) and field in computed_atoms) or (is_binary(field) and field in computed_strings) -> {query, false} # Custom field sort -> after load custom_field_sort?(field) -> {query, true} # DB field sort (atom) is_atom(field) -> {Ash.Query.sort(query, [{field, order}]), false} # DB field sort (string) -> convert only if allowed is_binary(field) -> case safe_member_field_atom_only(field) do nil -> {query, false} atom -> {Ash.Query.sort(query, [{atom, order}]), false} end true -> {query, false} end end defp valid_sort_field?(field) when is_atom(field) do if field in FieldVisibility.computed_member_fields(), do: false, else: valid_sort_field_db_or_custom?(field) end defp valid_sort_field?(field) when is_binary(field) do if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do false else valid_sort_field_db_or_custom?(field) end end defp valid_sort_field?(_), do: false defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do custom_field_sort?(field) or ((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom)) end defp safe_member_field_atom_only(str) do allowed = MapSet.new(Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) if MapSet.member?(allowed, str), do: String.to_existing_atom(str), else: nil end defp custom_field_sort?(field) when is_atom(field) do field_str = Atom.to_string(field) String.starts_with?(field_str, @custom_field_prefix) end defp custom_field_sort?(field) when is_binary(field) do String.starts_with?(field, @custom_field_prefix) end defp custom_field_sort?(_), do: false defp extract_custom_field_id(field) when is_atom(field) do field |> Atom.to_string() |> extract_custom_field_id() end defp extract_custom_field_id(field) when is_binary(field) do case String.split(field, @custom_field_prefix) do ["", id_str] -> id_str _ -> nil end end defp extract_custom_field_id(_), do: nil defp extract_custom_field_ids(visible_custom_fields) do Enum.map(visible_custom_fields, fn field_string -> case String.split(field_string, @custom_field_prefix) do ["", id] -> id _ -> nil end end) |> Enum.filter(&(&1 != nil)) end defp sort_members_in_memory(members, field, order, custom_fields) do custom_field_id_str = extract_custom_field_id(field) case custom_field_id_str do nil -> members id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) end end defp sort_members_by_custom_field(members, id_str, order, custom_fields) do custom_field = find_custom_field_by_id(custom_fields, id_str) case custom_field do nil -> members cf -> sort_members_with_custom_field(members, cf, order) end end defp find_custom_field_by_id(custom_fields, id_str) do Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) end defp sort_members_with_custom_field(members, custom_field, order) do {members_with_values, members_without_values} = split_members_by_value_presence(members, custom_field) sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) sorted_with_values ++ members_without_values end defp split_members_by_value_presence(members, custom_field) do Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end) end defp has_non_empty_value?(member, custom_field) do case get_custom_field_value(member, custom_field) do nil -> false cfv -> extracted = extract_sort_value(cfv.value, custom_field.value_type) not empty_value?(extracted, custom_field.value_type) end end defp sort_members_with_values(members_with_values, custom_field, order) do sorted = Enum.sort_by(members_with_values, fn member -> cfv = get_custom_field_value(member, custom_field) extracted = extract_sort_value(cfv.value, custom_field.value_type) normalize_sort_value(extracted, order) end) if order == :desc, do: Enum.reverse(sorted), else: sorted end defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type), do: extract_sort_value(value, type) defp extract_sort_value(value, :string) when is_binary(value), do: value defp extract_sort_value(value, :integer) when is_integer(value), do: value defp extract_sort_value(value, :boolean) when is_boolean(value), do: value defp extract_sort_value(%Date{} = date, :date), do: date defp extract_sort_value(value, :email) when is_binary(value), do: value defp extract_sort_value(value, _type), do: to_string(value) defp empty_value?(value, :string) when is_binary(value), do: String.trim(value) == "" defp empty_value?(value, :email) when is_binary(value), do: String.trim(value) == "" defp empty_value?(_value, _type), do: false defp normalize_sort_value(value, _order), do: value defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) socket |> assign(:sort_field, field) |> assign(:sort_order, order) end defp maybe_update_sort(socket, _), do: socket defp determine_field(default, ""), do: default defp determine_field(default, nil), do: default defp determine_field(default, sf) when is_binary(sf) do computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) if sf in computed_strings, do: default, else: determine_field_after_computed_check(default, sf) end defp determine_field(default, sf) when is_atom(sf) do if sf in FieldVisibility.computed_member_fields(), do: default, else: determine_field_after_computed_check(default, sf) end defp determine_field(default, _), do: default defp determine_field_after_computed_check(default, sf) when is_binary(sf) do if custom_field_sort?(sf) do if valid_sort_field?(sf), do: sf, else: default else atom = safe_member_field_atom_only(sf) if atom != nil and valid_sort_field?(atom), do: atom, else: default end end defp determine_field_after_computed_check(default, sf) when is_atom(sf) do if valid_sort_field?(sf), do: sf, else: default end defp determine_order(default, so) do case so do "" -> default nil -> default so when so in ["asc", "desc"] -> String.to_atom(so) _ -> default end end defp maybe_update_search(socket, %{"query" => query}) when query != "", do: assign(socket, :query, query) defp maybe_update_search(socket, _params), do: socket defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do filter = determine_cycle_status_filter(filter_str) assign(socket, :cycle_status_filter, filter) end defp maybe_update_cycle_status_filter(socket, _params), do: assign(socket, :cycle_status_filter, nil) defp determine_cycle_status_filter("paid"), do: :paid defp determine_cycle_status_filter("unpaid"), do: :unpaid defp determine_cycle_status_filter(_), do: nil defp maybe_update_boolean_filters(socket, params) do boolean_custom_fields = socket.assigns.all_custom_fields |> Enum.filter(&(&1.value_type == :boolean)) |> Map.new(fn cf -> {to_string(cf.id), cf} end) prefix_length = String.length(@boolean_filter_prefix) {filters, total_processed} = params |> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end) |> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} -> if count >= @max_boolean_filters do {:halt, {acc, count}} else new_acc = process_boolean_filter_param( key, value_str, prefix_length, boolean_custom_fields, acc ) {:cont, {new_acc, count + 1}} end end) if total_processed >= @max_boolean_filters do Logger.warning( "Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})" ) end assign(socket, :boolean_custom_field_filters, filters) end defp process_boolean_filter_param(key, value_str, prefix_length, boolean_custom_fields, acc) do custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length) if String.length(custom_field_id_str) > @max_uuid_length do acc else validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) end end defp validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) do case Ecto.UUID.cast(custom_field_id_str) do {:ok, _custom_field_id} -> add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) :error -> acc end end defp add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) do if Map.has_key?(boolean_custom_fields, custom_field_id_str) do case determine_boolean_filter(value_str) do nil -> acc filter_value -> Map.put(acc, custom_field_id_str, filter_value) end else acc end end defp determine_boolean_filter("true"), do: true defp determine_boolean_filter("false"), do: false defp determine_boolean_filter(_), do: nil defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}), do: assign(socket, :show_current_cycle, true) defp maybe_update_show_current_cycle(socket, _params), do: socket # ------------------------------------------------------------- # Custom Field Value Helpers # ------------------------------------------------------------- def get_custom_field_value(member, custom_field) do case member.custom_field_values do nil -> nil values when is_list(values) -> Enum.find(values, fn cfv -> cfv.custom_field_id == custom_field.id or (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id) end) _ -> nil end end def get_boolean_custom_field_value(member, custom_field) do case get_custom_field_value(member, custom_field) do nil -> nil cfv -> extract_boolean_value(cfv.value) end end defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}), do: extract_boolean_value(value) defp extract_boolean_value(value) when is_map(value) do type = Map.get(value, "type") || Map.get(value, "_union_type") val = Map.get(value, "value") || Map.get(value, "_union_value") if type == "boolean" or type == :boolean do extract_boolean_value(val) else nil end end defp extract_boolean_value(value) when is_boolean(value), do: value defp extract_boolean_value(nil), do: nil defp extract_boolean_value(_), do: nil def apply_boolean_custom_field_filters(members, filters, _all_custom_fields) when map_size(filters) == 0 do members end def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do valid_custom_field_ids = all_custom_fields |> Enum.filter(&(&1.value_type == :boolean)) |> MapSet.new(fn cf -> to_string(cf.id) end) valid_filters = Enum.filter(filters, fn {custom_field_id_str, _value} -> MapSet.member?(valid_custom_field_ids, custom_field_id_str) end) |> Enum.into(%{}) if map_size(valid_filters) == 0 do members else Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end) end end defp matches_all_filters?(member, filters) do Enum.all?(filters, fn {custom_field_id_str, filter_value} -> matches_filter?(member, custom_field_id_str, filter_value) end) end defp matches_filter?(member, custom_field_id_str, filter_value) do case find_custom_field_value_by_id(member, custom_field_id_str) do nil -> false cfv -> extract_boolean_value(cfv.value) == filter_value end end defp find_custom_field_value_by_id(member, custom_field_id_str) do case member.custom_field_values do nil -> nil values when is_list(values) -> Enum.find(values, fn cfv -> to_string(cfv.custom_field_id) == custom_field_id_str or (match?(%{custom_field: %{id: _}}, cfv) && to_string(cfv.custom_field.id) == custom_field_id_str) end) _ -> nil end end def format_selected_member_emails(members, selected_members) do members |> Enum.filter(fn member -> MapSet.member?(selected_members, member.id) && member.email && member.email != "" end) |> Enum.map(&format_member_email/1) end def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id}) def format_member_email(member) do first_name = member.first_name || "" last_name = member.last_name || "" name = [first_name, last_name] |> Enum.filter(&(&1 != "")) |> Enum.join(" ") if name == "", do: member.email, else: "#{name} <#{member.email}>" end def format_date(date), do: DateFormatter.format_date(date) defp update_selection_assigns(socket) do members = socket.assigns[:members] || [] selected_members = socket.assigns.selected_members selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) mailto_bcc = if any_selected? do format_selected_member_emails(members, selected_members) |> Enum.join(", ") |> URI.encode_www_form() else "" end socket |> assign(:selected_count, selected_count) |> assign(:any_selected?, any_selected?) |> assign(:mailto_bcc, mailto_bcc) |> assign_export_payload() end defp assign_export_payload(socket) do payload = build_export_payload(socket) assign(socket, :export_payload_json, Jason.encode!(payload)) end defp build_export_payload(socket) do visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] member_fields_db = socket.assigns[:member_fields_visible_db] || [] member_fields_computed = socket.assigns[:member_fields_visible_computed] || [] # Order DB member fields exactly like the table/constants ordered_member_fields_db = Mv.Constants.member_fields() |> Enum.filter(&(&1 in member_fields_db)) # Order computed fields in canonical order ordered_computed_fields = FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = socket.assigns.all_custom_fields |> Enum.map(&to_string(&1.id)) |> Enum.filter(&(&1 in visible_custom_field_ids)) %{ selected_ids: socket.assigns.selected_members |> MapSet.to_list(), member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), custom_field_ids: ordered_custom_field_ids, query: socket.assigns[:query] || nil, sort_field: export_sort_field(socket.assigns[:sort_field]), sort_order: export_sort_order(socket.assigns[:sort_order]), show_current_cycle: socket.assigns[:show_current_cycle] || false, cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]), boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{} } end defp export_cycle_status_filter(nil), do: nil defp export_cycle_status_filter(:paid), do: "paid" defp export_cycle_status_filter(:unpaid), do: "unpaid" defp export_cycle_status_filter(_), do: nil defp export_sort_field(nil), do: nil defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f) defp export_sort_field(f) when is_binary(f), do: f defp export_sort_order(nil), do: nil defp export_sort_order(:asc), do: "asc" defp export_sort_order(:desc), do: "desc" defp export_sort_order(o) when is_binary(o), do: o # ------------------------------------------------------------- # Internal utility: dedupe dropdown fields defensively # ------------------------------------------------------------- defp dedupe_available_fields(fields) when is_list(fields) do Enum.uniq_by(fields, fn item -> cond do is_map(item) -> Map.get(item, :key) || Map.get(item, :id) || Map.get(item, :field) || item is_tuple(item) and tuple_size(item) >= 1 -> elem(item, 0) true -> item end end) end defp dedupe_available_fields(other), do: other end