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 ## 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, 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(:selected_members, []) |> 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) ) # 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 id in socket.assigns.selected_members do List.delete(socket.assigns.selected_members, id) else [id | socket.assigns.selected_members] end {:noreply, assign(socket, :selected_members, selected)} end @impl true def handle_event("select_all", _params, socket) do members = socket.assigns.members all_ids = Enum.map(members, & &1.id) selected = if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do [] else all_ids end {:noreply, assign(socket, :selected_members, selected)} 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 = 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(socket, %{ "query" => q, "sort_field" => existing_field_query, "sort_order" => existing_sort_query }) # 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({: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 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) |> 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(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 # # 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, %{ "sort_field" => field_str, "sort_order" => Atom.to_string(order) }) 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 base_params |> Map.put("query", socket.assigns.query || "") |> 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 # Loads members from the database with custom field values and applies search/sort 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 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 (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 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 # 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 # ------------------------------------------------------------- # 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 # Gets the configuration for all member fields with their show_in_overview values. # # Reads the visibility configuration from Settings and returns a map with all member fields # and their show_in_overview values (true or false). 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 map: %{field_name => show_in_overview} # # This can be used for: # - Rendering the overview (filtering visible fields) # - UI configuration dropdowns (showing all fields with their current state) # - Dynamic field management # # Fields are read from the global Constants module. @spec get_member_field_configurations(map()) :: %{atom() => boolean()} defp get_member_field_configurations(settings) do # Get all eligible fields from the global constants all_fields = Mv.Constants.member_fields() # Normalize visibility config (JSONB may return string keys) visibility_config = normalize_visibility_config(settings.member_field_visibility || %{}) Enum.reduce(all_fields, %{}, fn field, acc -> show_in_overview = Map.get(visibility_config, field, true) Map.put(acc, field, show_in_overview) end) end # Normalizes visibility config map keys from strings to atoms. # JSONB in PostgreSQL converts atom keys to string keys when storing. # This is a local helper to avoid N+1 queries by reusing the normalization logic. defp normalize_visibility_config(config) when is_map(config) do Enum.reduce(config, %{}, fn {key, value}, acc when is_atom(key) -> Map.put(acc, key, value) {key, value}, acc when is_binary(key) -> try do atom_key = String.to_existing_atom(key) Map.put(acc, atom_key, value) rescue ArgumentError -> acc end _, acc -> acc end) end defp normalize_visibility_config(_), do: %{} # 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_") do ["", id] -> id _ -> nil end end) |> Enum.filter(&(&1 != nil)) end end