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 # 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 # 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 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 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(:custom_fields_visible, custom_fields_visible) |> 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 """ @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 = load_members(socket, q) 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(socket.assigns.query) # 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 # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @doc """ Handles URL parameter changes. Parses query parameters for search query, sort field, sort order, and payment filter, then loads members accordingly. This enables bookmarkable URLs and browser back/forward navigation. """ @impl true def handle_params(params, _url, socket) do socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_paid_filter(params) |> load_members(params["query"]) |> 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 # # Returns the socket with `:dynamic_cols` assigned. defp prepare_dynamic_cols(socket) do dynamic_cols = Enum.map(socket.assigns.custom_fields_visible, 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 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, search_query) do query = Mv.Membership.Member |> Ash.Query.new() |> Ash.Query.select(@overview_fields) # Load custom field values for visible custom fields custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) query = load_custom_field_values(query, custom_field_ids_list) # 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