defmodule MvWeb.MemberLive.Index do @moduledoc """ LiveView for displaying and managing the member list. ## Features - Full-text search across member profiles using PostgreSQL tsvector - DB-side filtering, sorting, and keyset pagination (infinite scroll) - Sortable columns, including composite Name (last→first) and Address (city→postal→street) - Column manager and per-browser view settings (density, composite Member/Address, include-email) - Bulk selection spanning the whole filtered set (select-all / copy emails / export) - Bookmarkable URLs: filter/sort/search/view state is URL-encoded ## URL Parameters All list state is URL-encoded so the view is bookmarkable and restored on reload: - `query` - full-text search string - `sort_field` / `sort_order` - sort column and direction (`asc` / `desc`) - `fields` - visible-column selection - `highlight` - member id to render as selected (return from the show page) - group / fee-type / boolean / date custom-field filter params (see `MvWeb.MemberLive.Index.FilterParams` and `DateFilter` for the encoding) ## Events (`handle_event`) - `select_member` / `select_all` - toggle individual / whole-filtered-set selection - `select_row_and_navigate` - open a member's show page - `load_more` - fetch and append the next keyset page (infinite scroll) - `sort` - change the active sort column/direction - `copy_emails` - copy emails of the selected members, or of the whole filtered set when none are selected ## Messages (`handle_info`, from the filter/search/view-settings components) - `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`, `fields_reset`, `reset_all_filters`, `clear_all_filters`, and the per-filter change messages (`group_filter_changed`, `fee_type_filter_changed`, `boolean_filter_changed`, `date_filters_changed`, `payment_filter_changed`, `payment_period_changed`) ## Implementation Notes - Filtering/sorting/pagination run in PostgreSQL via Ash; the socket holds only the loaded keyset window, not the whole table - List state is synced with the URL for bookmarkability; a signature guard avoids redundant reloads - 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.ExportPayload 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 alias MvWeb.MemberLive.Index.PaymentAging alias MvWeb.MemberLive.Index.ViewSettings 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) # Resolve the per-browser view settings first: the columns the manager offers # (composite vs. constituent Name/Address columns) follow these settings (§7b). view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket)) compact_member = view_settings.compact_member member_include_email = view_settings.member_include_email compact_address = view_settings.compact_address # The dropdown offers exactly the columns applicable to the current view # settings, so composite and constituent columns are never both listed. all_available_fields = FieldVisibility.get_offered_fields( all_custom_fields, compact_member, member_include_email, compact_address ) initial_selection = FieldVisibility.merge_with_global_settings( session_selection, settings, all_custom_fields, compact_member: compact_member, member_include_email: member_include_email, compact_address: compact_address ) 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(:payment_filter, nil) |> assign(:payment_period, PaymentAging.default_period()) |> 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(: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) |> assign_view_settings(view_settings) |> 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("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: ExportPayload.sort_field(socket.assigns.sort_field), sort_order: ExportPayload.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({:view_setting_toggled, key}, socket) do {:noreply, toggle_view_setting(socket, key)} end @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(:payment_filter, filter) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{payment_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({:payment_period_changed, period}, socket) do socket = socket |> assign(:payment_period, period) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = build_query_params(opts_for_query_params(socket, %{payment_period: period})) |> 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({:clear_all_filters}, socket) do handle_info( {:reset_all_filters, %{ payment_filter: nil, payment_period: PaymentAging.default_period(), group_filters: %{}, fee_type_filters: %{}, boolean_filters: %{}, date_filters: DateFilter.default() }}, socket ) 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 def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket |> assign(:payment_filter, Map.get(opts, :payment_filter)) |> assign(:payment_period, Map.get(opts, :payment_period, PaymentAging.default_period())) |> 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 = socket |> assign_field_visibility(new_selection) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() {:noreply, socket} end @impl true def handle_info({:fields_reset}, socket) do # Reset restores the curated default column set (§1.6): recompute the default # visibility from the global settings and re-apply it as the selection. {cm, ie, ca} = view_flags(socket) default_selection = FieldVisibility.merge_with_global_settings( %{}, socket.assigns.settings, socket.assigns.all_custom_fields, compact_member: cm, member_include_email: ie, compact_address: ca ) handle_info({:fields_selected, default_selection}, socket) end @impl true def handle_info({:fields_selected, selection}, socket) do socket = socket |> assign_field_visibility(selection) |> 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) socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_payment_filter(params) |> maybe_update_payment_period(params) |> maybe_update_group_filters(params) |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_date_filters(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) |> assign_visibility_derivations(final_selection) |> 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.payment_filter, socket.assigns.payment_period, socket.assigns[:group_filters], socket.assigns[:fee_type_filters], 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 -> if custom_field.value_type == :boolean do extract_boolean_value(cfv.value) else Formatter.format_custom_field_value(cfv.value, custom_field) end 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 # The current composite view flags; the offered column set is scoped to these. defp view_flags(socket) do {socket.assigns.compact_member, socket.assigns.member_include_email, socket.assigns.compact_address} end # Merges a raw selection with the global settings scoped to the current view # settings, then assigns it together with the visibility-derived assigns. defp assign_field_visibility(socket, selection) do {cm, ie, ca} = view_flags(socket) final = FieldVisibility.merge_with_global_settings( selection, socket.assigns.settings, socket.assigns.all_custom_fields, compact_member: cm, member_include_email: ie, compact_address: ca ) assign_visibility_derivations(socket, final) end # Assigns user_field_selection and every visibility-derived assign the table and # export payload read from an already merged (offered-scoped) selection. defp assign_visibility_derivations(socket, final_selection) do visible_member_fields = final_selection |> FieldVisibility.get_visible_member_fields() |> Enum.uniq() visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket |> assign(:user_field_selection, final_selection) |> assign(:member_fields_visible, visible_member_fields) |> assign( :member_fields_visible_db, FieldVisibility.get_visible_member_fields_db(final_selection) ) |> assign( :member_fields_visible_computed, FieldVisibility.get_visible_member_fields_computed(final_selection) ) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) 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_group_filters(base_params, opts.group_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) add_date_filters(base_params, opts.date_filters) end # Period-scoped payment model (§3.3): the payment-count filter and the active # period both serialize into the flat URL contract (pay_filter/pay_from/pay_to). defp add_payment_params(params, payment_filter, payment_period) do params |> Map.merge(PaymentAging.filter_to_params(payment_filter)) |> Map.merge(PaymentAging.to_params(payment_period || PaymentAging.default_period())) 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, payment_filter: socket.assigns.payment_filter, payment_period: socket.assigns.payment_period, group_filters: socket.assigns[:group_filters] || %{}, 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 {cm, ie, ca} = view_flags(socket) only_url = FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields, compact_member: cm, member_include_email: ie, compact_address: ca ) 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 {cm, ie, ca} = view_flags(socket) 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, compact_member: cm, member_include_email: ie, compact_address: ca ) 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 # The connect-info Plug.Conn (present on the initial dead render), used to read # per-browser cookies such as the persisted view settings. defp connect_conn(socket) do case socket.private[:connect_info] do %Plug.Conn{} = conn -> conn _ -> nil end end # The LiveView connect params (present only on the connected mount). The client # echoes the persisted view-settings cookie here because the connect-info map # of a live socket does not expose cookies. defp connect_params(socket) do if connected?(socket), do: get_connect_params(socket), else: nil end # Assigns the full view-settings map plus the derived per-setting assigns the # template renders from. defp assign_view_settings(socket, settings) do socket |> assign(:view_settings, settings) |> assign(:density, settings.density) |> assign(:compact_member, settings.compact_member) |> assign(:member_include_email, settings.member_include_email) |> assign(:compact_address, settings.compact_address) end # Updates a single view setting, re-derives the template assigns and persists # the whole settings map per browser (client writes the cookie; the connected # mount reads it back via connect params). defp update_view_setting(socket, key, value) do settings = Map.put(socket.assigns.view_settings, key, value) socket |> assign_view_settings(settings) |> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)}) end # Flips the density setting. This only re-renders the table wrapper attribute # (row spacing token), so the streamed rows do not need to be re-rendered. defp toggle_view_setting(socket, :density) do new = if socket.assigns.density == :compact, do: :comfortable, else: :compact update_view_setting(socket, :density, new) end # Flips the "include email" sub-toggle. Only meaningful while the composite # Member cell is on: it moves the email between an in-cell line (on) and a # separate email column (off), so besides re-rendering the rows it recomputes # which columns the manager offers and their visibility. defp toggle_view_setting(socket, :member_include_email) do new_value = not socket.assigns.view_settings.member_include_email socket |> update_view_setting(:member_include_email, new_value) |> apply_include_email_toggle(new_value) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() end # Flips the compact Member field. Besides re-rendering the rows, this changes # which columns the manager offers (composite "Name" vs. first-name/last-name/email, # §7b). The email visibility carries over between the two representations, # analogously to the "include email" sub-toggle: composite + include-email ⇄ the # email column being visible. defp toggle_view_setting(socket, :compact_member) do new_value = not socket.assigns.view_settings.compact_member email_visible? = email_currently_visible?(socket) socket |> update_view_setting(:compact_member, new_value) |> sync_include_email(new_value, email_visible?) |> apply_member_toggle(new_value, email_visible?) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() end # Flips the compact Address field: offers the composite "Address" vs. the # separate street/house-number/postal-code/city columns, making the newly relevant columns # visible and recomputing the offered set + visibility for the new mode. defp toggle_view_setting(socket, :compact_address) do new_value = not socket.assigns.view_settings.compact_address fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city] socket |> update_view_setting(:compact_address, new_value) |> apply_composite_toggle(fields) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() |> push_field_selection_url() end # Whether the member email is currently surfaced: while the composite is on, # either folded into the cell (include_email) or as a separate email column; # while it is off, as its own column. Read before the flip, so `compact_member` # still holds the previous mode. defp email_currently_visible?(socket) do if socket.assigns.compact_member do socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible else :email in socket.assigns.member_fields_visible end end # Turning include_email OFF surfaces the email as a separate column; make it # visible. Turning it ON folds the email into the cell, so the column is no # longer offered (the recompute drops it). defp apply_include_email_toggle(socket, false = _include_email) do selection = Map.put(socket.assigns.user_field_selection, "email", true) recompute_offered(socket, selection) end defp apply_include_email_toggle(socket, true = _include_email), do: recompute_offered(socket, socket.assigns.user_field_selection) # When switching to the composite, mirror the email state onto the include-email # sub-toggle so the email keeps being surfaced; when switching away, the # sub-toggle is not applicable. defp sync_include_email(socket, true = _new_compact, email_visible?), do: update_view_setting(socket, :member_include_email, email_visible?) defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket # Makes the now-relevant Member columns visible for the new mode. Turning the # composite on surfaces "Name" (with the email either folded via include_email # or kept as a separate column); turning it off surfaces first-name/last-name and # carries the prior email visibility onto the email column. defp apply_member_toggle(socket, true = _new_compact, email_visible?) do selection = socket.assigns.user_field_selection |> Map.put("name", true) |> Map.put("email", email_visible?) recompute_offered(socket, selection) end defp apply_member_toggle(socket, false = _new_compact, email_visible?) do selection = socket.assigns.user_field_selection |> Map.put("first_name", true) |> Map.put("last_name", true) |> Map.put("email", email_visible?) recompute_offered(socket, selection) end # Sets the given columns visible, then recomputes the offered set + visibility. defp apply_composite_toggle(socket, fields) do selection = Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc -> Map.put(acc, Atom.to_string(field), true) end) recompute_offered(socket, selection) end # Recomputes the offered column list and the derived visibility for the current # mode. `update_view_setting` has already updated the compact_* assigns, so # `view_flags/1` reflects the new mode here. defp recompute_offered(socket, selection) do {cm, ie, ca} = view_flags(socket) socket |> assign( :all_available_fields, FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca) ) |> assign_field_visibility(selection) 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_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() |> load_unpaid_cycle_count(socket) |> Ash.Query.load(groups: [:id, :name, :slug]) |> maybe_load_fee_type(socket) end # Loads the period-scoped unpaid-cycle count for the overview payment column # (§1.13, §3.3). The count is a DB aggregate over `membership_fee_cycles` # (denormalized `cycle_end`), scoped to the active period. defp load_unpaid_cycle_count(query, socket) do %{from: from, to: to} = socket.assigns.payment_period || PaymentAging.default_period() Ash.Query.load(query, unpaid_cycle_count: %{period_from: from, period_to: to}) 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], payment_filter: socket.assigns.payment_filter, payment_period: socket.assigns.payment_period, 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, :name, :address] 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 field == "name" -> :name field == "address" -> :address 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 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 # Computed/pseudo fields that are nonetheless sortable (they resolve to DB # sort keys in OverviewQuery): groups aggregate and the composite Name/Address # columns. Other computed fields (e.g. membership_fee_status) are not sortable. @sortable_computed_fields [:groups, :name, :address] defp determine_field(default, sf) when is_binary(sf) do sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1) if sf in sortable_strings do String.to_existing_atom(sf) 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 if sf in @sortable_computed_fields do sf 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 # Period-scoped payment model (§3.3): a payment-count filter plus the active # period, both driven purely from the URL so `handle_params` stays the source # of truth. Absent params fall back to no filter / the all-outstanding period. defp maybe_update_payment_filter(socket, params), do: assign(socket, :payment_filter, PaymentAging.parse_filter_params(params)) defp maybe_update_payment_period(socket, params), do: assign(socket, :payment_period, PaymentAging.parse_period(params)) 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 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 # 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 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 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) @doc """ The payment column header, naming the active aging period (§1.17). The all-outstanding default (both bounds nil) reads just "Payment"; a bounded period appends the range so the scope is always legible. """ def payment_column_label(%{from: nil, to: nil}), do: gettext("Fees") def payment_column_label(%{from: from, to: to}) do range = "#{payment_bound(from)}–#{payment_bound(to)}" gettext("Fees · %{range}", range: range) end def payment_column_label(_), do: gettext("Fees") defp payment_bound(%Date{} = d), do: Date.to_iso8601(d) defp payment_bound(_), do: "…" 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 "filtered" vs "all" 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[:payment_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 = ExportPayload.build(socket.assigns) assign(socket, :export_payload_json, Jason.encode!(payload)) end end