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 - `select_member` - Toggle individual member selection - `select_all` - Toggle selection of all visible members - `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected ## 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 import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.CustomFieldValueLookup alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.OverviewQuery require Ash.Query require Logger @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() @group_filter_prefix Mv.Constants.group_filter_prefix() @fee_type_filter_prefix Mv.Constants.fee_type_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) all_custom_fields = Mv.Membership.CustomField |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) custom_fields_visible = all_custom_fields |> Enum.filter(& &1.show_in_overview) # 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) # Date-typed custom fields surface in the new "Custom date fields" filter # section and are needed by DateFilter.from_params/2 to validate UUIDs. date_custom_fields = all_custom_fields |> Enum.filter(&(&1.value_type == :date)) |> Enum.sort_by(& &1.name, :asc) # Load groups for filter dropdown (sorted by name) groups = Mv.Membership.Group |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) # Load membership fee types for filter dropdown (sorted by name) fee_types = MembershipFeeType |> Ash.Query.sort(name: :asc) |> Ash.read!(domain: MembershipFees, actor: actor) # 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 # Ensure nested module is loaded (can be missing after code reload in dev if load order changes) Code.ensure_loaded!(FieldSelection) # Load user field selection from session session_selection = FieldSelection.get_from_session(session) # FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice) all_available_fields = all_custom_fields |> FieldVisibility.get_all_available_fields() initial_selection = FieldVisibility.merge_with_global_settings( session_selection, settings, all_custom_fields ) socket = socket |> Layouts.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(:group_filters, %{}) |> assign(:groups, groups) |> assign(:fee_type_filters, %{}) |> assign(:fee_types, fee_types) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) |> assign(:selected_member_id, nil) |> 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(:date_custom_fields, date_custom_fields) |> assign(:date_filters, DateFilter.default()) |> assign(:all_available_fields, all_available_fields) |> assign(:user_field_selection, initial_selection) |> assign(:fields_in_url?, false) |> assign( :member_fields_visible, FieldVisibility.get_visible_member_fields(initial_selection) ) |> assign( :member_fields_visible_db, FieldVisibility.get_visible_member_fields_db(initial_selection) ) |> assign( :member_fields_visible_computed, FieldVisibility.get_visible_member_fields_computed(initial_selection) ) |> assign(:show_current_cycle, false) |> assign(:membership_fee_status_filter, nil) |> assign(:page, nil) |> assign(:after_cursor, nil) |> assign(:more?, false) |> assign(:total_count, 0) |> assign(:loading?, false) # The bulk-actions dropdown's open state lives here so the mailto recipient # list can be fetched lazily on open (never on a mere selection change). |> assign(:bulk_actions_open, false) |> assign(:mailto_bcc, "") |> assign(:recipient_count, 0) |> assign(:mailto_disabled?, false) |> stream_configure(:members, dom_id: &"row-#{&1.id}") |> stream(:members, []) |> assign_export_payload() {:ok, socket} end # Number of members fetched per keyset page (matches the :overview action's # default_limit). Mount loads one page; further pages arrive via infinite # scroll (phx-viewport-bottom) so the socket never holds the full table. @page_limit 50 # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @doc """ Handles member-related UI events. ## Supported events: - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ @impl true def handle_event("select_row_and_navigate", %{"id" => id}, socket) do # Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected. {:noreply, push_navigate(socket, to: ~p"/members/#{id}")} 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) |> restream_member(id) |> update_selection_assigns()} end @impl true def handle_event("select_all", _params, socket) do # Select-all spans the whole filtered set, not just the loaded page (§1.17): # re-query every matching id. Toggling off when all are already selected. all_ids = all_filtered_member_ids(socket) selected = if MapSet.equal?(socket.assigns.selected_members, all_ids) do MapSet.new() else all_ids end {:noreply, socket |> assign(:selected_members, selected) |> restream_all_members() |> update_selection_assigns()} end # The bulk-actions dropdown routes its open/close here (un-targeted) so the # mailto recipient list is fetched only when the menu actually opens, not on # every selection or filter change. @impl true def handle_event("toggle_dropdown", _params, socket) do open? = !socket.assigns.bulk_actions_open socket = assign(socket, :bulk_actions_open, open?) socket = if open?, do: assign_mailto_recipients(socket), else: socket {:noreply, socket} end @impl true def handle_event("close_dropdown", _params, socket) do {:noreply, assign(socket, :bulk_actions_open, false)} end @impl true def handle_event("load_more", _params, socket) do {:noreply, socket |> load_more() |> 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() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end @impl true def handle_event("copy_emails", _params, socket) do # Recipients follow the current scope, re-queried from the DB so a no-selection # (all/filtered) copy or a select-all copy spans the whole filtered set rather # than only the loaded page (§1.17). Members without an email are excluded; # the empty-recipient feedback below is preserved. formatted_emails = scope_emails(socket) email_count = length(formatted_emails) if email_count == 0 do {:noreply, put_flash(socket, :error, gettext("No email addresses found"))} else # 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 @impl true def handle_event("sort", %{"field" => field_str}, socket) do # Handle both atom and string field names (for custom fields) field = try do String.to_existing_atom(field_str) rescue ArgumentError -> field_str end {new_field, new_order} = determine_new_sort(field, socket) old_field = socket.assigns.sort_field socket = socket |> assign(:sort_field, new_field) |> assign(:sort_order, new_order) |> update_sort_components(old_field, new_field, new_order) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() # URL sync - push_patch happens synchronously in the event handler query_params = build_query_params( opts_for_query_params(socket, %{ sort_field: export_sort_field(socket.assigns.sort_field), sort_order: export_sort_order(socket.assigns.sort_order) }) ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) {:noreply, push_reload(socket, ~p"/members?#{query_params}")} end # ----------------------------------------------------------------- # Handle Infos from Child Components # ----------------------------------------------------------------- @doc """ Handles messages from child components. ## Supported messages: - `{: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({:search_changed, q}, socket) do socket = socket |> assign(:query, q) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{query: q})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:payment_filter_changed, filter}, socket) do socket = socket |> assign(:cycle_status_filter, filter) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do updated_filters = if filter_value == nil do Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str) else Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value) end socket = socket |> assign(:boolean_custom_field_filters, updated_filters) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{boolean_filters: updated_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:group_filter_changed, group_id_str, filter_value}, socket) do normalized_id = normalize_uuid_string(group_id_str) || group_id_str group_filters = if filter_value == nil do Map.delete(socket.assigns.group_filters, normalized_id) else Map.put(socket.assigns.group_filters, normalized_id, filter_value) end socket = socket |> assign(:group_filters, group_filters) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{group_filters: group_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:fee_type_filter_changed, fee_type_id_str, filter_value}, socket) do normalized_id = normalize_uuid_string(fee_type_id_str) || fee_type_id_str fee_type_filters = if filter_value == nil do Map.delete(socket.assigns.fee_type_filters, normalized_id) else Map.put(socket.assigns.fee_type_filters, normalized_id, filter_value) end socket = socket |> assign(:fee_type_filters, fee_type_filters) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{fee_type_filters: fee_type_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:date_filters_changed, new_date_filters}, socket) do socket = socket |> assign(:date_filters, new_date_filters) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end # Backward compatibility: tuple form delegates to map form def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do handle_info( {:reset_all_filters, %{ cycle_status_filter: cycle_status_filter, boolean_filters: boolean_filters, group_filters: %{}, fee_type_filters: %{} }}, socket ) end def handle_info( {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters}, socket ) do handle_info( {:reset_all_filters, %{ cycle_status_filter: cycle_status_filter, boolean_filters: boolean_filters, group_filters: group_filters, fee_type_filters: %{} }}, socket ) end def handle_info( {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, fee_type_filters}, socket ) do handle_info( {:reset_all_filters, %{ cycle_status_filter: cycle_status_filter, boolean_filters: boolean_filters, group_filters: group_filters, fee_type_filters: fee_type_filters }}, socket ) end def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket |> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter)) |> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:field_toggled, field_string, visible}, socket) do new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) socket = update_session_field_selection(socket, new_selection) final_selection = FieldVisibility.merge_with_global_settings( new_selection, socket.assigns.settings, socket.assigns.all_custom_fields ) visible_member_fields = final_selection |> FieldVisibility.get_visible_member_fields() |> Enum.uniq() visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) visible_member_fields_computed = FieldVisibility.get_visible_member_fields_computed(final_selection) visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() {:noreply, socket} end @impl true def handle_info({:fields_selected, selection}, socket) do socket = update_session_field_selection(socket, selection) final_selection = FieldVisibility.merge_with_global_settings( selection, socket.assigns.settings, socket.assigns.all_custom_fields ) visible_member_fields = final_selection |> FieldVisibility.get_visible_member_fields() |> Enum.uniq() visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) visible_member_fields_computed = FieldVisibility.get_visible_member_fields_computed(final_selection) visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() {:noreply, socket} end # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @impl true def handle_params(params, url, socket) do url = url || request_url_from_socket(socket) params = merge_fields_param_from_uri(params, url) prev_sig = build_signature(socket) fields_in_url? = case Map.get(params, "fields") do v when is_binary(v) and v != "" -> true _ -> false end url_selection = FieldSelection.parse_from_url(params) final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket) visible_member_fields = final_selection |> FieldVisibility.get_visible_member_fields() |> Enum.uniq() visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) visible_member_fields_computed = FieldVisibility.get_visible_member_fields_computed(final_selection) visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_cycle_status_filter(params) |> maybe_update_group_filters(params) |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_date_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:selected_member_id, parse_highlight_param(params["highlight"])) next_sig = build_signature(socket) socket = if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do socket |> prepare_dynamic_cols() |> update_selection_assigns() else socket |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() end # The (re)load is complete: clear the busy flag set by `push_reload/2` (§1.10). socket = assign(socket, :loading?, false) # Update sort components after rendering socket = if socket.assigns[:sort_needs_update] do old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field socket |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) |> assign(:sort_needs_update, false) |> assign(:previous_sort_field, nil) else socket end {:noreply, socket} end defp build_signature(socket) do { socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, socket.assigns[:group_filters], socket.assigns[:fee_type_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, socket.assigns[:visible_custom_field_ids] || [], socket.assigns[:date_filters] } end defp prepare_dynamic_cols(socket) do visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] visible_set = MapSet.new(visible_custom_field_ids) dynamic_cols = socket.assigns.all_custom_fields |> Enum.filter(fn custom_field -> MapSet.member?(visible_set, to_string(custom_field.id)) end) |> Enum.map(fn custom_field -> %{ custom_field: custom_field, render: fn member -> case get_custom_field_value(member, custom_field) do nil -> "" cfv -> Formatter.format_custom_field_value(cfv.value, custom_field) end end } end) assign(socket, :dynamic_cols, dynamic_cols) end # ------------------------------------------------------------- # Sorting # ------------------------------------------------------------- defp determine_new_sort(field, socket) do if socket.assigns.sort_field == field do {field, toggle_order(socket.assigns.sort_order)} else {field, :asc} end end defp update_sort_components(socket, old_field, new_field, new_order) do active_id = to_sort_id(new_field) old_id = to_sort_id(old_field) send_update(MvWeb.Components.SortHeaderComponent, id: active_id, sort_field: new_field, sort_order: new_order ) send_update(MvWeb.Components.SortHeaderComponent, id: old_id, sort_field: new_field, sort_order: new_order ) socket end defp to_sort_id(field) when is_binary(field) do String.to_existing_atom("sort_#{field}") rescue ArgumentError -> :"sort_#{field}" end defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}" # Only keep `fields` in the URL when it was already present (bookmark/share), # OR when we intentionally push it via push_field_selection_url/1. defp maybe_add_field_selection(params, selection, true) when is_map(selection) do fields_param = FieldSelection.to_url_param(selection) if fields_param == "" do Map.delete(params, "fields") else Map.put(params, "fields", fields_param) end end defp maybe_add_field_selection(params, _selection, _include?), do: params defp push_field_selection_url(socket) do query_params = build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" push_reload(socket, new_path) end defp update_session_field_selection(socket, selection) do assign(socket, :user_field_selection, selection) end defp build_query_params(opts) when is_map(opts) do base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order) base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter) base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) base_params = add_show_current_cycle(base_params, opts.show_current_cycle) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) add_date_filters(base_params, opts.date_filters) end defp add_date_filters(params, date_filters) do Map.merge(params, DateFilter.to_params(date_filters)) end defp opts_for_query_params(socket, overrides \\ %{}) do %{ query: socket.assigns.query, sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, cycle_status_filter: socket.assigns.cycle_status_filter, group_filters: socket.assigns[:group_filters] || %{}, show_current_cycle: socket.assigns.show_current_cycle, boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, fee_type_filters: socket.assigns[:fee_type_filters] || %{}, date_filters: socket.assigns.date_filters } |> Map.merge(overrides) end defp add_fee_type_filters(params, fee_type_filters) do Enum.reduce(fee_type_filters, params, fn {fee_type_id_str, value}, acc -> param_value = if value == :in, do: "in", else: "not_in" Map.put(acc, "#{@fee_type_filter_prefix}#{fee_type_id_str}", param_value) end) end defp compute_final_field_selection(true, url_selection, socket) do only_url = FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) visible_members = FieldVisibility.get_visible_member_fields(only_url) visible_custom = FieldVisibility.get_visible_custom_fields(only_url) if visible_members == [] and visible_custom == [] do # URL had only invalid field names; fall back to session + global. compute_final_field_selection(false, url_selection, socket) else only_url end end defp compute_final_field_selection(false, url_selection, socket) do merged = FieldSelection.merge_sources( url_selection, socket.assigns.user_field_selection, %{} ) FieldVisibility.merge_with_global_settings( merged, socket.assigns.settings, socket.assigns.all_custom_fields ) end # On full page load conn.params has no query string; read "fields" from URI so column visibility is restored. defp request_url_from_socket(socket) do case socket.private[:connect_info] do %Plug.Conn{} = conn -> Plug.Conn.request_url(conn) _ -> nil end end # Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid. defp parse_highlight_param(nil), do: nil defp parse_highlight_param(""), do: nil defp parse_highlight_param(id) when is_binary(id) do if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)), do: id, else: nil end defp parse_highlight_param(_), do: nil defp merge_fields_param_from_uri(params, nil), do: params defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do case URI.decode_query(query)["fields"] do nil -> params value -> Map.put(params, "fields", value) end end defp merge_fields_param_from_uri(params, %URI{}), do: params defp merge_fields_param_from_uri(params, url) when is_binary(url) do case URI.parse(url).query do nil -> params q -> case URI.decode_query(q)["fields"] do nil -> params value -> Map.put(params, "fields", value) end end end defp merge_fields_param_from_uri(params, _), do: params defp build_base_params(query, sort_field, sort_order) do %{ "query" => query || "", "sort_field" => normalize_sort_field(sort_field), "sort_order" => normalize_sort_order(sort_order) } end defp normalize_sort_field(nil), do: "" defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field) defp normalize_sort_field(field) when is_binary(field), do: field defp normalize_sort_field(_), do: "" defp normalize_sort_order(nil), do: "" defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order) defp normalize_sort_order(order) when is_binary(order), do: order defp normalize_sort_order(_), do: "" defp add_group_filters(params, group_filters) do Enum.reduce(group_filters, params, fn {group_id_str, value}, acc -> param_value = if value == :in, do: "in", else: "not_in" Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value) end) end defp add_cycle_status_filter(params, nil), do: params defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid") defp add_cycle_status_filter(params, :unpaid), do: Map.put(params, "cycle_status_filter", "unpaid") defp add_cycle_status_filter(params, _), do: params defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true") defp add_show_current_cycle(params, _), do: params defp add_boolean_filters(params, boolean_filters) do Enum.reduce(boolean_filters, params, &add_boolean_filter/2) end defp add_boolean_filter({custom_field_id, filter_value}, acc) do 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 # ------------------------------------------------------------- # Loading members # ------------------------------------------------------------- # Loads the first keyset page of the overview and resets the stream. All # filtering and sorting run in PostgreSQL via the `:overview` read action # (see OverviewQuery); the socket holds only the loaded window, never the # whole table. defp load_members(socket) do # `count: true` runs one COUNT for the active filter set so the live region # can announce the exact total matching count (§1.9), not just the loaded # page size. page = read_overview_page(socket, limit: @page_limit, count: true) socket |> assign(:members, index_by_id(page.results)) |> assign(:after_cursor, next_cursor(page)) |> assign(:more?, page.more?) |> assign(:total_count, page.count || length(page.results)) |> AshPhoenix.LiveView.assign_page_and_stream_result(page, results_key: :members, page_key: :page, stream_opts: [reset: true] ) end # Returns the members scroll container to the top after a sort/filter reset. # A reset re-streams from page 1, so a user who was scrolled down would be left # past the (now shorter) content with infinite scroll not re-arming; scrolling # back to the top shows the new ordering from #1 and re-arms the loader. Only # used on genuine resets, never on load_more (which must preserve position). # The client handler (app.js) is a no-op if the container is not found. defp scroll_list_to_top(socket), do: push_event(socket, "members:scroll-top", %{}) # Marks the table region busy (aria-busy, §1.10) for the reload triggered by a # filter/sort/search patch, then hands off to the patch. `handle_params` clears # the flag once the page has been (re)loaded. defp push_reload(socket, path) do socket |> assign(:loading?, true) |> push_patch(to: path, replace: true) end # Fetches the next keyset page (if any) and appends it to the stream and the # loaded window. Triggered by phx-viewport-bottom; a no-op once the last page # has been reached so the bottom sentinel stops fetching. defp load_more(%{assigns: %{more?: false}} = socket), do: socket defp load_more(socket) do page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor) socket = Enum.reduce(page.results, socket, fn member, acc -> stream_insert(acc, :members, member) end) socket |> assign(:members, Map.merge(socket.assigns.members, index_by_id(page.results))) |> assign(:after_cursor, next_cursor(page)) |> assign(:more?, page.more?) |> assign(:page, %{page | results: nil}) end # Loaded members are held as a `%{id => member}` map (not a list): appends on # load_more stay O(page) via Map.merge instead of copying an ever-growing list # with `++`, and a per-row selection re-render is an O(1) lookup by id. defp index_by_id(members), do: Map.new(members, &{&1.id, &1}) # Re-inserts a single loaded member into the stream so its row re-renders with # the current selection state (checkbox + selected highlight). Stream rows are # not re-rendered on assign changes, so a selection toggle must re-stream the # affected row. defp restream_member(socket, id) do case Map.get(socket.assigns[:members] || %{}, id) do nil -> socket member -> stream_insert(socket, :members, member) end end # Re-inserts every loaded member so all visible rows reflect a bulk selection # change (select-all / deselect-all). defp restream_all_members(socket) do Enum.reduce(Map.values(socket.assigns[:members] || %{}), socket, fn member, acc -> stream_insert(acc, :members, member) end) end # Reads one keyset page of the overview with display loads applied. Reads run # as the real user actor so Ash policies are enforced. defp read_overview_page(socket, page_opts) do socket |> overview_query() |> Ash.read!(actor: current_actor(socket), page: page_opts) end # The cursor of the last loaded row, used as the `after` bound for the next # page; nil once there are no further pages. defp next_cursor(%{more?: true, results: results}) when results != [], do: List.last(results).__metadata__.keyset defp next_cursor(_page), do: nil # Builds the `:overview` query (filter/sort via OverviewQuery) and layers on # the display-only loads (custom field values, cycles, groups, fee type). defp overview_query(socket) do socket |> overview_query_opts() |> OverviewQuery.build() |> Ash.Query.select(@overview_fields) |> load_custom_field_values(compute_ids_to_load(socket)) |> MembershipFeeStatus.load_cycles_for_members(socket.assigns.show_current_cycle) |> Ash.Query.load(groups: [:id, :name, :slug]) |> maybe_load_fee_type(socket) end defp maybe_load_fee_type(query, socket) do if :membership_fee_type in socket.assigns.member_fields_visible or socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do Ash.Query.load(query, membership_fee_type: [:id, :name]) else query end end # Assembles the filter/sort options OverviewQuery understands from the current # LiveView assigns. defp overview_query_opts(socket) do %{ search: socket.assigns.query, group_filters: socket.assigns[:group_filters], groups: socket.assigns[:groups], fee_type_filters: socket.assigns[:fee_type_filters], fee_types: socket.assigns[:fee_types], boolean_custom_field_filters: socket.assigns.boolean_custom_field_filters, boolean_custom_fields: socket.assigns.boolean_custom_fields, date_filters: socket.assigns.date_filters, date_custom_fields: socket.assigns[:date_custom_fields], cycle_status_filter: socket.assigns.cycle_status_filter, show_current_cycle: socket.assigns.show_current_cycle, sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, custom_fields: socket.assigns.all_custom_fields } end # Collects every custom field UUID whose values must be loaded for a given # render — visible columns plus any active boolean or date filter. Kept as a # standalone helper so load_members/1 stays under the credo complexity bar. defp compute_ids_to_load(socket) do visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] boolean_custom_fields_map = socket.assigns.boolean_custom_fields |> Map.new(fn cf -> {to_string(cf.id), cf} end) active_boolean_filter_ids = socket.assigns.boolean_custom_field_filters |> Map.keys() |> Enum.filter(fn id_str -> String.length(id_str) <= @max_uuid_length && match?({:ok, _}, Ecto.UUID.cast(id_str)) && Map.has_key?(boolean_custom_fields_map, id_str) end) date_custom_fields = socket.assigns[:date_custom_fields] || [] active_date_filter_ids = DateFilter.active_custom_field_ids( socket.assigns.date_filters, date_custom_fields ) (visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids) |> Enum.uniq() end defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do custom_field_values_query = Mv.Membership.CustomFieldValue |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids)) |> Ash.Query.load(custom_field: [:id, :name, :value_type]) query |> Ash.Query.load(custom_field_values: custom_field_values_query) end # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp toggle_order(nil), do: :asc defp valid_sort_field?(field) when is_atom(field) do # :groups is in computed_member_fields() but can be sorted # Only :membership_fee_status should be blocked if field == :membership_fee_status do false else valid_sort_field_db_or_custom?(field) end end defp valid_sort_field?(field) when is_binary(field) do # "groups" is in computed_member_fields() but can be sorted # Only "membership_fee_status" should be blocked if field == "membership_fee_status" do false else valid_sort_field_db_or_custom?(field) end end defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type] end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do normalized = cond do field == "groups" -> :groups field == "membership_fee_type" -> :membership_fee_type true -> safe_member_field_atom_only(field) end (normalized != nil and valid_sort_field_db_or_custom?(normalized)) or custom_field_sort?(field) end defp safe_member_field_atom_only(str) do allowed = MapSet.new(Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) if MapSet.member?(allowed, str), do: String.to_existing_atom(str), else: nil end defp custom_field_sort?(field) when is_atom(field) do field_str = Atom.to_string(field) String.starts_with?(field_str, @custom_field_prefix) end defp custom_field_sort?(field) when is_binary(field) do String.starts_with?(field, @custom_field_prefix) end defp custom_field_sort?(_), do: false defp extract_custom_field_ids(visible_custom_fields) do Enum.map(visible_custom_fields, fn field_string -> case String.split(field_string, @custom_field_prefix) do ["", id] -> id _ -> nil end end) |> Enum.filter(&(&1 != nil)) end defp 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) old_field = socket.assigns.sort_field socket |> assign(:sort_field, field) |> assign(:sort_order, order) |> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order) |> assign(:previous_sort_field, old_field) end defp maybe_update_sort(socket, _), do: socket defp determine_field(default, ""), do: default defp determine_field(default, nil), do: default defp determine_field(default, sf) when is_binary(sf) do # Handle "groups" specially - it's in computed_member_fields() but can be sorted if sf == "groups" do :groups else computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) if sf in computed_strings, do: default, else: determine_field_after_computed_check(default, sf) end end defp determine_field(default, sf) when is_atom(sf) do # Handle :groups specially - it's in computed_member_fields() but can be sorted if sf == :groups do :groups else if sf in FieldVisibility.computed_member_fields(), do: default, else: determine_field_after_computed_check(default, sf) end end defp determine_field(default, _), do: default defp determine_field_after_computed_check(default, sf) when is_binary(sf) do cond do sf == "groups" -> :groups custom_field_sort?(sf) -> if valid_sort_field?(sf), do: sf, else: default true -> atom = safe_member_field_atom_only(sf) if atom != nil and valid_sort_field?(atom), do: atom, else: default end end defp determine_field_after_computed_check(default, sf) when is_atom(sf) do if valid_sort_field?(sf), do: sf, else: default end defp determine_order(default, so) do case so do "" -> default nil -> default so when so in ["asc", "desc"] -> String.to_atom(so) _ -> default end end defp maybe_update_search(socket, %{"query" => query}) when query != "", do: assign(socket, :query, query) defp maybe_update_search(socket, _params), do: socket defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do filter = determine_cycle_status_filter(filter_str) assign(socket, :cycle_status_filter, filter) end defp maybe_update_cycle_status_filter(socket, _params), do: assign(socket, :cycle_status_filter, nil) defp maybe_update_group_filters(socket, params) when is_map(params) do prefix = @group_filter_prefix prefix_len = String.length(prefix) group_param_entries = params |> Enum.filter(fn {key, _} -> key_str = to_string(key) String.starts_with?(key_str, prefix) end) filters = Enum.reduce(group_param_entries, %{}, fn {key, value_str}, acc -> add_group_filter_entry(acc, key, value_str, prefix_len) end) valid_group_ids = socket.assigns.groups |> Enum.map(&normalize_uuid_string(to_string(&1.id))) |> Enum.reject(&is_nil/1) |> MapSet.new() |> MapSet.to_list() assign(socket, :group_filters, Map.take(filters, valid_group_ids)) end defp maybe_update_fee_type_filters(socket, params) when is_map(params) do prefix = @fee_type_filter_prefix prefix_len = String.length(prefix) fee_type_param_entries = params |> Enum.filter(fn {key, _} -> key_str = to_string(key) String.starts_with?(key_str, prefix) end) filters = Enum.reduce(fee_type_param_entries, %{}, fn {key, value_str}, acc -> add_fee_type_filter_entry(acc, key, value_str, prefix_len) end) valid_fee_type_ids = socket.assigns.fee_types |> Enum.map(&normalize_uuid_string(to_string(&1.id))) |> Enum.reject(&is_nil/1) |> MapSet.new() |> MapSet.to_list() assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids)) end defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do key_str = to_string(key) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) fee_type_id_str = normalize_uuid_string(raw_id) valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length if valid_id? do case FilterParams.parse_in_not_in_value(value_str) do nil -> acc value -> Map.put(acc, fee_type_id_str, value) end else acc end end defp add_group_filter_entry(acc, key, value_str, prefix_len) do key_str = to_string(key) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) group_id_str = normalize_uuid_string(raw_id) valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length if valid_id? do case FilterParams.parse_in_not_in_value(value_str) do nil -> acc value -> Map.put(acc, group_id_str, value) end else acc end end # Normalize UUID string so URL params match valid_ids (lowercase, canonical format) defp normalize_uuid_string(raw) when is_binary(raw) do case Ecto.UUID.cast(String.trim(raw)) do {:ok, uuid} -> to_string(uuid) _ -> raw end end defp normalize_uuid_string(_), do: nil defp determine_cycle_status_filter("paid"), do: :paid defp determine_cycle_status_filter("unpaid"), do: :unpaid defp determine_cycle_status_filter(_), do: nil defp maybe_update_boolean_filters(socket, params) do boolean_custom_fields = socket.assigns.all_custom_fields |> Enum.filter(&(&1.value_type == :boolean)) |> Map.new(fn cf -> {to_string(cf.id), cf} end) prefix_length = String.length(@boolean_filter_prefix) {filters, total_processed} = params |> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end) |> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} -> if count >= @max_boolean_filters do {:halt, {acc, count}} else new_acc = process_boolean_filter_param( key, value_str, prefix_length, boolean_custom_fields, acc ) {:cont, {new_acc, count + 1}} end end) if total_processed >= @max_boolean_filters do Logger.warning( "Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})" ) end assign(socket, :boolean_custom_field_filters, filters) end defp process_boolean_filter_param(key, value_str, prefix_length, boolean_custom_fields, acc) do custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length) if String.length(custom_field_id_str) > @max_uuid_length do acc else validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) end end defp validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) do case Ecto.UUID.cast(custom_field_id_str) do {:ok, _custom_field_id} -> add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) :error -> acc end end defp add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) do if Map.has_key?(boolean_custom_fields, custom_field_id_str) do case determine_boolean_filter(value_str) do nil -> acc filter_value -> Map.put(acc, custom_field_id_str, filter_value) end else acc end end defp determine_boolean_filter("true"), do: true defp determine_boolean_filter("false"), do: false defp determine_boolean_filter(_), do: nil defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}), do: assign(socket, :show_current_cycle, true) defp maybe_update_show_current_cycle(socket, _params), do: socket # URL params are the source of truth for filter state on every navigation. # When no date filter params are present, this falls through to the # active_only default — exactly the spec behavior for fresh load (§1.1). defp maybe_update_date_filters(socket, params) when is_map(params) do date_custom_fields = socket.assigns[:date_custom_fields] || [] assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields)) end # ------------------------------------------------------------- # Custom Field Value Helpers # ------------------------------------------------------------- def get_custom_field_value(member, custom_field) do CustomFieldValueLookup.find_by_field(member, custom_field) end def get_boolean_custom_field_value(member, custom_field) do case get_custom_field_value(member, custom_field) do nil -> nil cfv -> extract_boolean_value(cfv.value) end end defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}), do: extract_boolean_value(value) defp extract_boolean_value(value) when is_map(value) do type = Map.get(value, "type") || Map.get(value, "_union_type") val = Map.get(value, "value") || Map.get(value, "_union_value") if type == "boolean" or type == :boolean do extract_boolean_value(val) else nil end end defp extract_boolean_value(value) when is_boolean(value), do: value defp extract_boolean_value(nil), do: nil defp extract_boolean_value(_), do: nil def apply_boolean_custom_field_filters(members, filters, _all_custom_fields) when map_size(filters) == 0 do members end def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do valid_custom_field_ids = all_custom_fields |> Enum.filter(&(&1.value_type == :boolean)) |> MapSet.new(fn cf -> to_string(cf.id) end) valid_filters = Enum.filter(filters, fn {custom_field_id_str, _value} -> MapSet.member?(valid_custom_field_ids, custom_field_id_str) end) |> Enum.into(%{}) if map_size(valid_filters) == 0 do members else Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end) end end defp matches_all_filters?(member, filters) do Enum.all?(filters, fn {custom_field_id_str, filter_value} -> matches_filter?(member, custom_field_id_str, filter_value) end) end defp matches_filter?(member, custom_field_id_str, filter_value) do case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do nil -> false cfv -> extract_boolean_value(cfv.value) == filter_value end end def format_selected_member_emails(members, selected_members) do members |> Enum.filter(fn member -> MapSet.member?(selected_members, member.id) && member.email && member.email != "" end) |> Enum.map(&format_member_email/1) end def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id}) def format_member_email(member) do first_name = member.first_name || "" last_name = member.last_name || "" name = [first_name, last_name] |> Enum.filter(&(&1 != "")) |> Enum.join(" ") if name == "", do: member.email, else: "#{name} <#{member.email}>" end def format_date(date), do: DateFormatter.format_date(date) defp update_selection_assigns(socket) do selected_members = socket.assigns.selected_members # The selection may span members beyond the loaded page (after select-all), # so its size is the MapSet size, not a count over the loaded window. selected_count = MapSet.size(selected_members) any_selected? = selected_count > 0 # Scope drives the trigger label: the selection when present, otherwise the # whole list (filtered, when a search term or any filter is active). scope = cond do any_selected? -> :selection filters_active?(socket.assigns) -> :filtered true -> :all end # No DB work here: the mailto recipient list is computed lazily when the # bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or # filter change never reads members for a link the user may never open. socket |> assign(:selected_count, selected_count) |> assign(:scope, scope) |> assign_export_payload() end # Fetches the mailto BCC recipients for the current bulk scope. Called only when # the bulk-actions dropdown opens (the mailto item, a native anchor, is rendered # only then), keeping selection toggles free of DB reads. defp assign_mailto_recipients(socket) do selected_count = MapSet.size(socket.assigns.selected_members) # Size of the whole bulk scope (§1.17): the selection, or the full filtered # total — never just the loaded page. scope_size = if selected_count > 0, do: selected_count, else: socket.assigns.total_count cap = Mv.Constants.max_mailto_bulk_recipients() mailto_disabled? = scope_size >= cap # Mailto recipients are bounded by the cap; only re-query the (small) scope # when it is within the cap, otherwise the link is disabled anyway. recipient_emails = if mailto_disabled?, do: [], else: scope_emails(socket, limit: cap) recipient_count = if mailto_disabled?, do: scope_size, else: length(recipient_emails) # RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +) mailto_bcc = recipient_emails |> Enum.join(", ") |> URI.encode_www_form() |> String.replace("+", "%20") socket |> assign(:recipient_count, recipient_count) |> assign(:mailto_disabled?, mailto_disabled?) |> assign(:mailto_bcc, mailto_bcc) end # Formatted "Name " recipients for the current bulk scope, re-queried # from the DB so the set spans the whole selection / filtered total rather than # only the loaded page (§1.17). Members without an email are excluded. defp scope_emails(socket, opts \\ []) do socket |> scope_members(opts) |> Enum.filter(fn member -> member.email && member.email != "" end) |> Enum.map(&format_member_email/1) end # MapSet of every member id matching the active filters (no pagination), used # by select-all so the selection spans the full filtered set (§1.17). defp all_filtered_member_ids(socket) do socket |> overview_query_opts() |> Map.put(:sort_field, nil) |> OverviewQuery.build() |> Ash.Query.select([:id]) |> Ash.read!(actor: current_actor(socket)) |> MapSet.new(& &1.id) end # Members matching the current bulk scope: the selected members when any are # selected, otherwise every member matching the active filters. Re-queried as # the real user actor so policies are enforced. defp scope_members(socket, opts) do selected = socket.assigns.selected_members query = if MapSet.size(selected) > 0 do ids = MapSet.to_list(selected) Mv.Membership.Member |> Ash.Query.for_read(:overview) |> Ash.Query.filter(expr(id in ^ids)) else socket |> overview_query_opts() |> Map.put(:sort_field, nil) |> OverviewQuery.build() end |> Ash.Query.select([:id, :first_name, :last_name, :email]) query = if opts[:limit], do: Ash.Query.limit(query, opts[:limit]), else: query Ash.read!(query, actor: current_actor(socket)) end @doc """ Returns true when the member list is restricted by a non-empty search term or any active filter (cycle status, group, fee type, boolean custom field, or a date filter differing from the default). Drives the "(gefiltert)" vs "(alle)" trigger label and reads only assigns — no DB access. """ def filters_active?(assigns) do search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns) end defp search_active?(assigns) do query = assigns[:query] is_binary(query) and query != "" end defp selection_filters_active?(assigns) do not is_nil(assigns[:cycle_status_filter]) or map_size(assigns[:group_filters] || %{}) > 0 or map_size(assigns[:fee_type_filters] || %{}) > 0 or map_size(assigns[:boolean_custom_field_filters] || %{}) > 0 end defp date_filter_active?(assigns) do (assigns[:date_filters] || DateFilter.default()) != DateFilter.default() end defp assign_export_payload(socket) do payload = build_export_payload(socket) assign(socket, :export_payload_json, Jason.encode!(payload)) end defp build_export_payload(socket) do visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] member_fields_db = socket.assigns[:member_fields_visible_db] || [] member_fields_computed = socket.assigns[:member_fields_visible_computed] || [] # Order DB member fields exactly like the table/constants ordered_member_fields_db = Mv.Constants.member_fields() |> Enum.filter(&(&1 in member_fields_db)) # Order computed fields in canonical order ordered_computed_fields = FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) member_fields_with_groups = build_export_member_fields_list( ordered_member_fields_db, socket.assigns[:member_fields_visible] ) # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = socket.assigns.all_custom_fields |> Enum.map(&to_string(&1.id)) |> Enum.filter(&(&1 in visible_custom_field_ids)) %{ selected_ids: socket.assigns.selected_members |> MapSet.to_list(), member_fields: Enum.map(member_fields_with_groups, fn f when is_atom(f) -> Atom.to_string(f) f when is_binary(f) -> f end), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), custom_field_ids: ordered_custom_field_ids, column_order: export_column_order( ordered_member_fields_db, ordered_computed_fields, ordered_custom_field_ids, :membership_fee_type in socket.assigns[:member_fields_visible], :groups in socket.assigns[:member_fields_visible] ), query: socket.assigns[:query] || nil, sort_field: export_sort_field(socket.assigns[:sort_field]), sort_order: export_sort_order(socket.assigns[:sort_order]), show_current_cycle: socket.assigns[:show_current_cycle] || false, cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]), boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{} } end defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do if f == "membership_fee_start_date" do extra = if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++ if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: [] [f] ++ extra else [f] end end defp build_export_member_fields_list(ordered_db, member_fields_visible) do with_extras = Enum.flat_map(ordered_db, fn f -> if f == :membership_fee_start_date and :membership_fee_type in (member_fields_visible || []) do [f, :membership_fee_type] else [f] end end) # If fee type is visible but start_date was not in the list, append it with_extras = if :membership_fee_type in (member_fields_visible || []) and :membership_fee_type not in with_extras do with_extras ++ [:membership_fee_type] else with_extras end if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras end defp export_cycle_status_filter(nil), do: nil defp export_cycle_status_filter(:paid), do: "paid" defp export_cycle_status_filter(:unpaid), do: "unpaid" defp export_cycle_status_filter(_), do: nil defp export_sort_field(nil), do: nil defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f) defp export_sort_field(f) when is_binary(f), do: f defp export_sort_order(nil), do: nil defp export_sort_order(:asc), do: "asc" defp export_sort_order(:desc), do: "desc" defp export_sort_order(o) when is_binary(o), do: o # Build a single ordered list that matches the table order: # - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db) # - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible # - groups appended before custom fields when visible # - custom fields appended in the same order as table (already ordered_custom_field_ids) defp export_column_order( ordered_member_fields_db, ordered_computed_fields, ordered_custom_field_ids, membership_fee_type_visible, groups_visible ) do db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1) computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1) # Place membership_fee_type and membership_fee_status after membership_fee_start_date when present db_with_extras = Enum.flat_map( db_strings, &expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings) ) # If fee type is visible but start_date was not in the list, append it before computed/groups db_with_extras = if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do db_with_extras ++ ["membership_fee_type"] else db_with_extras end # Any remaining computed fields not inserted above (future-proof) remaining_computed = computed_strings |> Enum.reject(&(&1 in db_with_extras)) result = db_with_extras ++ remaining_computed result = if groups_visible, do: result ++ ["groups"], else: result result ++ ordered_custom_field_ids end end