mitgliederverwaltung/lib/mv_web/live/member_live/index.ex

1956 lines
68 KiB
Elixir

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
- `toggle_cycle_view` - switch the payment-status view between current and last cycle
- `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`, and the per-filter change messages
(`group_filter_changed`, `fee_type_filter_changed`, `boolean_filter_changed`,
`date_filters_changed`, `payment_filter_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.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(: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)
|> 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("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: 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(: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
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 =
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_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_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.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 ->
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_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
{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_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()
|> 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, :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
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
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)
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 <email>" 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[: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 = ExportPayload.build(socket.assigns)
assign(socket, :export_payload_json, Jason.encode!(payload))
end
end