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 # Prefix used in sort field names for custom fields (e.g., "custom_field_") @custom_field_prefix Mv.Constants.custom_field_prefix() # Prefix used for boolean custom field filter URL parameters (e.g., "bf_") @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) # 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(: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(:show_current_cycle, false) |> assign(:membership_fee_status_filter, nil) # 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 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() # Update URL to reflect cycle view change 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) 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() |> update_selection_assigns() 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.cycle_status_filter, socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) # 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(:cycle_status_filter, filter) |> load_members() |> update_selection_assigns() # 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, 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 # Update boolean filters map updated_filters = if filter_value == nil do # Remove filter if nil (All option selected) Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str) else # Add or update filter 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() # 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, 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 # Reset all filters at once (performance optimization) # This avoids NĂ—2 load_members() calls when resetting multiple filters socket = socket |> assign(:cycle_status_filter, cycle_status_filter) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() # Build the URL with all params including reset filters 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 # 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 (use all_custom_fields to allow enabling globally hidden fields) final_selection = FieldVisibility.merge_with_global_settings( new_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() |> prepare_dynamic_cols() |> update_selection_assigns() |> 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() |> prepare_dynamic_cols() |> update_selection_assigns() |> 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 # Build signature BEFORE updates to detect if anything actually changed prev_sig = build_signature(socket) # 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) # Apply all updates 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(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) # Build signature AFTER updates next_sig = build_signature(socket) # Only load members if signature changed (optimization: avoid duplicate loads) socket = if prev_sig == next_sig do # Nothing changed, skip expensive load_members() call socket |> prepare_dynamic_cols() |> update_selection_assigns() else # Signature changed, reload members socket |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() end {:noreply, socket} end # Builds a signature tuple representing all filter/sort parameters that affect member loading. # # This signature is used to detect if member data needs to be reloaded when handle_params # is called. If the signature hasn't changed, we can skip the expensive load_members() call. # # Returns a tuple containing all relevant parameters: # - query: Search query string # - sort_field: Field to sort by # - sort_order: Sort direction (:asc or :desc) # - cycle_status_filter: Payment filter (:paid, :unpaid, or nil) # - show_current_cycle: Whether to show current cycle # - boolean_custom_field_filters: Map of active boolean filters # - user_field_selection: Map of user's field visibility selections # - visible_custom_field_ids: List of visible custom field IDs (affects which custom fields are loaded) 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 # 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] || [] # Use all_custom_fields to allow users to enable globally hidden custom fields dynamic_cols = socket.assigns.all_custom_fields |> 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.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 # 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.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 # 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 cycle_status_filter atom to string for URL. # Adds boolean custom field filters as bf_=true|false. 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 } # Only add cycle_status_filter to URL if it's set 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 # Add show_current_cycle if true base_params = if show_current_cycle do Map.put(base_params, "show_current_cycle", "true") else base_params end # Add boolean custom field filters 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 # 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 AND active boolean filters # This ensures boolean filters work even when the custom field is not visible in overview visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] # Get IDs of active boolean filters (whitelisted against boolean_custom_fields) # Convert boolean_custom_fields list to map for efficient lookup (consistent with maybe_update_boolean_filters) 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 -> # Validate UUID format and check against whitelist String.length(id_str) <= @max_uuid_length && match?({:ok, _}, Ecto.UUID.cast(id_str)) && Map.has_key?(boolean_custom_fields_map, id_str) end) # Union of visible IDs and active filter IDs ids_to_load = (visible_custom_field_ids ++ active_boolean_filter_ids) |> Enum.uniq() query = load_custom_field_values(query, ids_to_load) # Load membership fee cycles for status display query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) # Apply the search filter first query = apply_search_filter(query, search_query) # 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 ) # 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) time_milliseconds = time_microseconds / 1000 Logger.info("Ash.read! in load_members/1 took #{time_milliseconds} 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 (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) 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 cycle status filter to members list. # # Filter values: # - nil: No filter, return all members # - :paid: Only members with paid status in the selected cycle (last or current) # - :unpaid: Only members with unpaid status in the selected cycle (last or current) 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 # 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] 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 # Extracts custom field IDs from visible custom field strings # Format: "custom_field_" -> 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 # 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 cycle status filter from URL parameters if present. # # Validates the filter value, falling back to nil (no filter) if invalid. 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 # Reset filter if not in URL params assign(socket, :cycle_status_filter, nil) end # Determines valid cycle status filter from URL parameter. # # SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid" # are accepted - all other input (including malicious strings) falls back to nil. # This ensures no raw user input is ever passed to filter functions. defp determine_cycle_status_filter("paid"), do: :paid defp determine_cycle_status_filter("unpaid"), do: :unpaid defp determine_cycle_status_filter(_), do: nil # Updates boolean custom field filters from URL parameters if present. # # Parses all URL parameters with prefix @boolean_filter_prefix and validates them: # - Extracts custom field ID from parameter name (explicitly removes prefix) # - Validates filter value using determine_boolean_filter/1 # - Whitelisting: Only custom field IDs that exist and have value_type: :boolean # - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks # - Security: Validates UUID length (max @max_uuid_length characters) # # Returns socket with updated :boolean_custom_field_filters assign. defp maybe_update_boolean_filters(socket, params) do # Get all boolean custom fields for whitelisting (keyed by ID as string for consistency) boolean_custom_fields = socket.assigns.all_custom_fields |> Enum.filter(&(&1.value_type == :boolean)) |> Map.new(fn cf -> {to_string(cf.id), cf} end) # Parse all boolean filter parameters # Security: Use reduce_while to abort early after @max_boolean_filters to prevent DoS attacks # This protects CPU/Parsing costs, not just memory/state # We count processed parameters (not just valid filters) to protect against parsing DoS 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 ) # Increment counter for each processed parameter (DoS protection) # Note: We count processed params, not just valid filters, to protect parsing costs {:cont, {new_acc, count + 1}} end end) # Log warning if we hit the limit 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 # Processes a single boolean filter parameter from URL params. # # Validates the parameter and adds it to the accumulator if valid. # Returns the accumulator unchanged if validation fails. defp process_boolean_filter_param( key, value_str, prefix_length, boolean_custom_fields, acc ) do # Extract custom field ID from parameter name (explicitly remove prefix) # This is more secure than String.replace_prefix which only removes first occurrence custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length) # Validate custom field ID length (UUIDs are max @max_uuid_length characters) # This provides an additional security layer beyond UUID format validation 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 # Validates UUID format and custom field existence, then adds filter if valid. 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 # Adds boolean filter to accumulator if custom field exists and value is valid. 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 # Determines valid boolean filter value from URL parameter. # # SECURITY: This function whitelists allowed filter values. Only "true" and "false" # are accepted - all other input (including malicious strings) falls back to nil. # This ensures no raw user input is ever passed to filter functions. # # Returns: # - `true` for "true" string # - `false` for "false" string # - `nil` for any other value defp determine_boolean_filter("true"), do: true defp determine_boolean_filter("false"), do: false defp determine_boolean_filter(_), do: nil # Updates show_current_cycle from URL parameters if present. defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do assign(socket, :show_current_cycle, true) end defp maybe_update_show_current_cycle(socket, _params) do socket end # ------------------------------------------------------------- # 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 (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id) end) _ -> nil end end # Extracts the boolean value from a member's custom field value. # # Handles different value formats: # - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union # - Map format with `"type"` and `"value"` keys - Extracts from map # - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map # # Returns: # - `true` if the custom field value is boolean true # - `false` if the custom field value is boolean false # - `nil` if no custom field value exists, value is nil, or value is not boolean # # Examples: # get_boolean_custom_field_value(member, boolean_field) -> true # get_boolean_custom_field_value(member, non_existent_field) -> nil 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 # Extracts boolean value from custom field value, handling different formats. # # Handles: # - `%Ash.Union{value: value, type: :boolean}` - Union struct format # - Map with `"type"` and `"value"` keys - JSONB map format # - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format # - Direct boolean value - Primitive boolean # # Returns `true`, `false`, or `nil`. defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do extract_boolean_value(value) end defp extract_boolean_value(value) when is_map(value) do # Handle map format from JSONB 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 # Applies boolean custom field filters to a list of members. # # Filters members based on boolean custom field values. Only members that match # ALL active filters (AND logic) are returned. # # Parameters: # - `members` - List of Member resources with loaded custom_field_values # - `filters` - Map of `%{custom_field_id_string => true | false}` # - `all_custom_fields` - List of all CustomField resources (for validation) # # Returns: # - Filtered list of members that match all active filters # - All members if filters map is empty # - Filters with non-existent custom field IDs are ignored # # Examples: # apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...] # apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members 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 # Build a map of valid boolean custom field IDs (as strings) for quick lookup valid_custom_field_ids = all_custom_fields |> Enum.filter(&(&1.value_type == :boolean)) |> MapSet.new(fn cf -> to_string(cf.id) end) # Filter out invalid custom field IDs from filters 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 no valid filters remain, return all members if map_size(valid_filters) == 0 do members else Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end) end end # Checks if a member matches all active boolean filters. # # A member matches a filter if: # - The filter value is `true` and the member's custom field value is `true` # - The filter value is `false` and the member's custom field value is `false` # # Members without a custom field value or with `nil` value do not match any filter. # # Returns `true` if all filters match, `false` otherwise. 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 # Checks if a member matches a specific boolean filter. # # Finds the custom field value by ID and checks if the member's boolean value # matches the filter value. # # Returns: # - `true` if the member's boolean value matches the filter value # - `false` if no custom field value exists (member is filtered out) # - `false` if value is nil or values don't match 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 -> boolean_value = extract_boolean_value(cfv.value) boolean_value == filter_value end end # Finds a custom field value by custom field ID string. # # Searches through the member's custom_field_values to find one matching # the given custom field ID. # # Returns the CustomFieldValue or nil. 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 # Filters selected members with email addresses and formats them. # Returns a list of formatted email strings in the format "First Last ". # Used by both copy_emails and mailto links. 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 @doc """ Returns a JS command to toggle member selection when clicking the checkbox column. Used as `col_click` handler to ensure clicking anywhere in the checkbox column toggles the checkbox instead of navigating to the member details. """ def checkbox_column_click(member) do JS.push("select_member", value: %{id: member.id}) end # Formats a member's email in the format "First Last " # Used for copy_emails feature and mailto links to create email-client-friendly format. 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 end # Public helper function to format dates for use in templates def format_date(date), do: DateFormatter.format_date(date) # Updates selection-related assigns (selected_count, any_selected?, mailto_bcc) # to avoid recalculating Enum.any? and Enum.count multiple times in templates. # # Note: Mailto URLs have length limits that vary by email client. # For large selections, consider using export functionality instead. # # Handles case where members haven't been loaded yet (e.g., when signature didn't change in handle_params). defp update_selection_assigns(socket) do # Handle case where members haven't been loaded yet (e.g., when signature didn't change) 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) end end