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 import Ash.Expr alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility # Prefix used in sort field names for custom fields (e.g., "custom_field_") @custom_field_prefix "custom_field_" # 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) # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # and result in a 500 error page. This is appropriate for LiveViews where errors # should be visible to the user rather than silently failing. custom_fields_visible = Mv.Membership.CustomField |> Ash.Query.filter(expr(show_in_overview == true)) |> Ash.Query.sort(name: :asc) |> Ash.read!() # 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!() # 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) # Get all available fields (for dropdown - includes ALL custom fields) all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields) # Merge session selection with global settings for initial state (use all_custom_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(:paid_filter, nil) |> assign(:selected_members, MapSet.new()) |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) |> assign(:all_custom_fields, all_custom_fields) |> assign(:all_available_fields, all_available_fields) |> assign(:user_field_selection, initial_selection) |> assign(:member_field_configurations, get_member_field_configurations(settings)) |> assign( :member_fields_visible, FieldVisibility.get_visible_member_fields(initial_selection) ) |> assign(:member_fields_visible, get_visible_member_fields(settings)) # We call handle params to use the query from the URL {: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 # Note: Using bang versions (!) - errors will be handled by Phoenix LiveView # This ensures users see error messages if deletion fails (e.g., permission denied) member = Ash.get!(Mv.Membership.Member, id) Ash.destroy!(member) updated_members = Enum.reject(socket.assigns.members, &(&1.id == id)) {:noreply, assign(socket, :members, updated_members)} 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, assign(socket, :selected_members, selected)} 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, assign(socket, :selected_members, selected)} 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 = socket.assigns.members |> Enum.filter(fn member -> MapSet.member?(selected_ids, member.id) && member.email && member.email != "" end) |> Enum.map(&format_member_email/1) 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 # ----------------------------------------------------------------- # 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) socket |> update_sort_components(socket.assigns.sort_field, new_field, new_order) |> push_sort_url(new_field, new_order) end @impl true def handle_info({:search_changed, q}, socket) do socket = socket |> assign(:query, q) |> load_members() existing_field_query = socket.assigns.sort_field existing_sort_query = socket.assigns.sort_order # Build the URL with queries query_params = build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter) # Set the new path with params new_path = ~p"/members?#{query_params}" # Push the new URL {:noreply, push_patch(socket, to: new_path, replace: true )} end @impl true def handle_info({:payment_filter_changed, filter}, socket) do socket = socket |> assign(:paid_filter, filter) |> load_members() # Build the URL with all params including new filter query_params = build_query_params( socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, filter ) 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 # Update user field selection new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) # Save to session (cookie will be saved on next page load via handle_params) socket = update_session_field_selection(socket, new_selection) # Merge with global settings final_selection = FieldVisibility.merge_with_global_settings( new_selection, socket.assigns.settings, socket.assigns.custom_fields_visible ) # Get visible fields visible_member_fields = FieldVisibility.get_visible_member_fields(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(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members(socket.assigns.query) |> prepare_dynamic_cols() |> push_field_selection_url() {:noreply, socket} end @impl true def handle_info({:fields_selected, selection}, socket) do # Save to session socket = update_session_field_selection(socket, selection) # Merge with global settings (use all_custom_fields for merging) final_selection = FieldVisibility.merge_with_global_settings( selection, socket.assigns.settings, socket.assigns.all_custom_fields ) # Get visible fields visible_member_fields = FieldVisibility.get_visible_member_fields(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(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members(socket.assigns.query) |> prepare_dynamic_cols() |> push_field_selection_url() {:noreply, socket} end # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @doc """ Handles URL parameter changes. Parses query parameters for search query, sort field, sort order, and payment filter, and field selection, then loads members accordingly. This enables bookmarkable URLs and browser back/forward navigation. """ @impl true def handle_params(params, _url, socket) do # Parse field selection from URL url_selection = FieldSelection.parse_from_url(params) # Merge with session selection (URL has priority) merged_selection = FieldSelection.merge_sources( url_selection, socket.assigns.user_field_selection, %{} ) # Merge with global settings (use all_custom_fields for merging) final_selection = FieldVisibility.merge_with_global_settings( merged_selection, socket.assigns.settings, socket.assigns.all_custom_fields ) # Get visible fields visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_paid_filter(params) |> assign(:query, params["query"]) |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() {:noreply, socket} end # Prepares dynamic column definitions for custom fields that should be shown in the overview. # # Creates a list of column definitions, each containing: # - `:custom_field` - The CustomField resource # - `:render` - A function that formats the custom field value for a given member # # Only includes custom fields that are visible according to user field selection. # # Returns the socket with `:dynamic_cols` assigned. defp prepare_dynamic_cols(socket) do visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] dynamic_cols = socket.assigns.custom_fields_visible |> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids 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 # ------------------------------------------------------------- # FUNCTIONS # ------------------------------------------------------------- # Determines new sort field and order based on current state 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 # Updates both the active and old SortHeader components 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) # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, id: active_id, sort_field: new_field, sort_order: new_order ) # Reset the current SortHeader send_update(MvWeb.Components.SortHeaderComponent, id: old_id, sort_field: new_field, sort_order: new_order ) socket end # Converts a field (atom or string) to a sort component ID atom # Handles both existing atoms and strings that need to be converted 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}" end # Builds sort URL and pushes navigation patch 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.paid_filter ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true )} end # Builds query parameters including field selection defp build_query_params(socket, base_params) do # Use query from base_params if provided, otherwise fall back to socket.assigns.query query_value = Map.get(base_params, "query") || socket.assigns.query || "" base_params |> Map.put("query", query_value) |> maybe_add_field_selection(socket.assigns[:user_field_selection]) end # Adds field selection to query params if present 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 # Pushes URL with updated field selection defp push_field_selection_url(socket) do query_params = build_query_params(socket, %{ "sort_field" => field_to_string(socket.assigns.sort_field), "sort_order" => Atom.to_string(socket.assigns.sort_order) }) new_path = ~p"/members?#{query_params}" push_patch(socket, to: new_path, replace: true) end # Converts field to string defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) defp field_to_string(field) when is_binary(field), do: field # Updates session field selection (stored in socket for now, actual session update via controller) defp update_session_field_selection(socket, selection) do # Store in socket for now - actual session persistence would require a controller # This is a placeholder for future session persistence assign(socket, :user_field_selection, selection) end # Builds URL query parameters map including all filter/sort state. # Converts paid_filter atom to string for URL. defp build_query_params(query, sort_field, sort_order, paid_filter) 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 } # Only add paid_filter to URL if it's set case paid_filter do nil -> base_params :paid -> Map.put(base_params, "paid_filter", "paid") :not_paid -> Map.put(base_params, "paid_filter", "not_paid") end end # Loads members from the database with custom field values and applies search/sort/payment filters. # # Process: # 1. Builds base query with selected fields # 2. Loads custom field values for visible custom fields (filtered at database level) # 3. Applies search filter if provided # 4. Applies payment status filter if set # 5. Applies sorting (database-level for regular fields, in-memory for custom fields) # # Performance Considerations: # - Database-level filtering: Custom field values are filtered directly in the database # using Ash relationship filters, reducing memory usage and improving performance. # - In-memory sorting: Custom field sorting is done in memory after loading. # This is suitable for small to medium datasets (<1000 members). # For larger datasets, consider implementing database-level sorting or pagination. # - No pagination: All matching members are loaded at once. For large result sets, # consider implementing pagination (see Issue #165). # # Returns the socket with `:members` assigned. defp load_members(socket) do search_query = socket.assigns.query query = Mv.Membership.Member |> Ash.Query.new() |> Ash.Query.select(@overview_fields) # Load custom field values for visible custom fields (based on user selection) visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] query = load_custom_field_values(query, visible_custom_field_ids) # Apply the search filter first query = apply_search_filter(query, search_query) # Apply payment status filter query = apply_paid_filter(query, socket.assigns.paid_filter) # Apply sorting based on current socket state # For custom fields, we sort after loading {query, sort_after_load} = maybe_sort( query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible ) # Note: Using Ash.read! - errors will be handled by Phoenix LiveView # This is appropriate for data loading in LiveViews members = Ash.read!(query) # Custom field values are already filtered at the database level in load_custom_field_values/2 # No need for in-memory filtering anymore # Sort in memory if needed (for custom fields) members = if sort_after_load do sort_members_in_memory( members, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible ) else members end assign(socket, :members, members) end # Load custom field values for the given custom field IDs # # Filters custom field values directly in the database using Ash relationship filters. # This is more efficient than loading all values and filtering in memory. # # Performance: Database-level filtering reduces: # - Memory usage (only visible custom field values are loaded) # - Network transfer (less data from database to application) # - Processing time (no need to iterate through all members and filter) defp load_custom_field_values(query, []) do query end defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do # Filter custom field values at the database level using Ash relationship query # This ensures only visible custom field values are loaded 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 # ------------------------------------------------------------- # Function to apply search query 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 # Applies payment status filter to the query. # # Filter values: # - nil: No filter, return all members # - :paid: Only members with paid == true # - :not_paid: Members with paid == false or paid == nil (not paid) defp apply_paid_filter(query, nil), do: query defp apply_paid_filter(query, :paid) do Ash.Query.filter(query, expr(paid == true)) end defp apply_paid_filter(query, :not_paid) do # Include both false and nil as "not paid" # Note: paid != true doesn't work correctly with NULL values in SQL Ash.Query.filter(query, expr(paid == false or is_nil(paid))) end # Functions to toggle sorting order 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 # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory defp maybe_sort(query, nil, _, _), do: {query, false} defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do if custom_field_sort?(field) do # Custom fields need to be sorted in memory after loading {query, true} else # Only sort by atom fields (regular member fields) in database if is_atom(field) do {Ash.Query.sort(query, [{field, order}]), false} else {query, false} end end end defp maybe_sort(query, _, _, _), do: {query, false} # Validate that a field is sortable # Uses member fields from constants, but excludes fields that don't make sense to sort # (e.g., :notes is too long, :paid is boolean and not very useful for sorting) defp valid_sort_field?(field) when is_atom(field) do # All member fields are sortable, but we exclude some that don't make sense # :id is not in member_fields, but we don't want to sort by it anyway non_sortable_fields = [:notes, :paid] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) end defp valid_sort_field?(field) when is_binary(field) do custom_field_sort?(field) end defp valid_sort_field?(_), do: false # Check if field is a custom field sort field (format: custom_field_) 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 # Extracts the custom field ID from a sort field name. # # Sort fields for custom fields use the format: "custom_field_" # This function extracts the ID part. # # Examples: # extract_custom_field_id("custom_field_123") -> "123" # extract_custom_field_id(:custom_field_123) -> "123" # extract_custom_field_id("first_name") -> nil defp extract_custom_field_id(field) when is_atom(field) do field_str = Atom.to_string(field) extract_custom_field_id(field_str) 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 # Sorts members in memory by a custom field value. # # Process: # 1. Extracts custom field ID from sort field name # 2. Finds the corresponding CustomField resource # 3. Splits members into those with values and those without # 4. Sorts members with values by the extracted value # 5. Combines: sorted values first, then NULL/empty values at the end # # Performance Note: # This function sorts in memory, which is suitable for small to medium datasets (<1000 members). # For larger datasets, consider implementing database-level sorting or pagination. # # Parameters: # - `members` - List of Member resources to sort # - `field` - Sort field name (format: "custom_field_" or atom) # - `order` - Sort order (`:asc` or `:desc`) # - `custom_fields` - List of visible CustomField resources # # Returns the sorted list of members. 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 # Sorts members by a specific custom field ID 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 # Finds a custom field by matching its ID string 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 # Sorts members that have a specific custom field defp sort_members_with_custom_field(members, custom_field, order) do # Split members into those with values and those without (NULL/empty) {members_with_values, members_without_values} = split_members_by_value_presence(members, custom_field) # Sort members with values sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) # Combine: sorted values first, then NULL/empty values at the end sorted_with_values ++ members_without_values end # Splits members into those with values and those without 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 # Checks if a member has a non-empty value for the custom field 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 # Sorts members that have values for the custom field 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) # For DESC, reverse only the members with values if order == :desc do Enum.reverse(sorted) else sorted end end # Extracts a sortable value from a custom field value based on its type. # # Handles different value formats: # - `%Ash.Union{}` - Extracts value and type from union # - Direct values - Returns as-is for primitive types # # Returns the extracted value suitable for sorting. defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do extract_sort_value(value, type) end 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) # Check if a value is considered empty (NULL or empty string) defp empty_value?(value, :string) when is_binary(value) do String.trim(value) == "" end defp empty_value?(value, :email) when is_binary(value) do String.trim(value) == "" end defp empty_value?(_value, _type), do: false # Normalize sort value for DESC order # For DESC, we sort ascending first, then reverse the list # This function is kept for consistency but doesn't need to invert values defp normalize_sort_value(value, _order), do: value # Updates sort field and order from URL parameters if present. # # Validates the sort field and order, falling back to defaults if invalid. 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 # Determine sort field from URL parameter, validating against allowed fields defp determine_field(default, ""), do: default defp determine_field(default, nil), do: default # Determines the valid sort field from a URL parameter. # # Validates the field against allowed sort fields (regular member fields or custom fields). # Falls back to default if the field is invalid. # # Parameters: # - `default` - Default field to use if validation fails # - `sf` - Sort field from URL (can be atom, string, nil, or empty string) # # Returns a valid sort field (atom or string for custom fields). defp determine_field(default, sf) when is_binary(sf) do # Check if it's a custom field sort (starts with "custom_field_") if custom_field_sort?(sf) do if valid_sort_field?(sf), do: sf, else: default else # Try to convert to atom for regular fields try do atom = String.to_existing_atom(sf) if valid_sort_field?(atom), do: atom, else: default rescue ArgumentError -> default end end end defp determine_field(default, sf) when is_atom(sf) do if valid_sort_field?(sf), do: sf, else: default end defp determine_field(default, _), do: default # Determines the valid sort order from a URL parameter. # # Validates that the order is either "asc" or "desc", falling back to default if invalid. # # Parameters: # - `default` - Default order to use if validation fails # - `so` - Sort order from URL (string, atom, nil, or empty string) # # Returns `:asc` or `:desc`. 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 # Function to update search parameters defp maybe_update_search(socket, %{"query" => query}) when query != "" do assign(socket, :query, query) end defp maybe_update_search(socket, _params) do # Keep the previous search query if no new one is provided socket end # Updates paid filter from URL parameters if present. # # Validates the filter value, falling back to nil (no filter) if invalid. defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do filter = determine_paid_filter(filter_str) assign(socket, :paid_filter, filter) end defp maybe_update_paid_filter(socket, _params) do # Reset filter if not in URL params assign(socket, :paid_filter, nil) end # Determines valid paid filter from URL parameter. # # SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid" # are accepted - all other input (including malicious strings) falls back to nil. # This ensures no raw user input is ever passed to Ash.Query.filter/2, following # Ash's security recommendation to never pass untrusted input directly to filters. defp determine_paid_filter("paid"), do: :paid defp determine_paid_filter("not_paid"), do: :not_paid defp determine_paid_filter(_), do: nil # ------------------------------------------------------------- # Helper Functions for Custom Field Values # ------------------------------------------------------------- # Retrieves the custom field value for a specific member and custom field. # # Searches through the member's `custom_field_values` relationship to find # the value matching the given custom field. # # Returns: # - `%CustomFieldValue{}` if found # - `nil` if not found or if member has no custom field values # # Examples: # get_custom_field_value(member, custom_field) -> %CustomFieldValue{...} # get_custom_field_value(member, non_existent_field) -> nil 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 (cfv.custom_field && cfv.custom_field.id == custom_field.id) end) _ -> nil end end # Formats a member's email in the format "First Last " # Used for copy_emails feature to create email-client-friendly format. defp 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 end # Gets the list of member fields that should be visible in the overview. # # Reads the visibility configuration from Settings and returns only the fields # where show_in_overview is true. Fields not configured in settings default to true. # # Performance: This function uses the already-loaded settings to avoid N+1 queries. # Settings should be loaded once in mount/3 and passed to this function. # # Parameters: # - `settings` - The settings struct loaded from the database # # Returns a list of atoms representing visible member field names. # # Fields are read from the global Constants module. @spec get_visible_member_fields(map()) :: [atom()] defp get_visible_member_fields(settings) do # Get all eligible fields from the global constants all_fields = Mv.Constants.member_fields() # JSONB stores keys as strings visibility_config = settings.member_field_visibility || %{} # Filter to only return visible fields Enum.filter(all_fields, fn field -> Map.get(visibility_config, Atom.to_string(field), true) end) end end