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

2138 lines
73 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
- Sortable columns (name, email, address fields)
- Bulk selection for future batch operations
- Real-time updates via LiveView
- Bookmarkable URLs with query parameters
## URL Parameters
- `query` - Search query string for full-text search
- `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
- `sort_order` - Sort direction (:asc or :desc)
## Events
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
- Sort state is synced with URL for bookmarkability
- Components communicate via `handle_info` for decoupling
"""
use MvWeb, :live_view
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.FilterParams
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
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: export_sort_field(socket.assigns.sort_field),
sort_order: export_sort_order(socket.assigns.sort_order)
})
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
{:noreply, push_reload(socket, ~p"/members?#{query_params}")}
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@doc """
Handles messages from child components.
## Supported messages:
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({: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
# Backward compatibility: tuple form delegates to map form
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info(
{:reset_all_filters,
%{
cycle_status_filter: cycle_status_filter,
boolean_filters: boolean_filters,
group_filters: %{},
fee_type_filters: %{}
}},
socket
)
end
def handle_info(
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
socket
) do
handle_info(
{:reset_all_filters,
%{
cycle_status_filter: cycle_status_filter,
boolean_filters: boolean_filters,
group_filters: group_filters,
fee_type_filters: %{}
}},
socket
)
end
def handle_info(
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters,
fee_type_filters},
socket
) do
handle_info(
{:reset_all_filters,
%{
cycle_status_filter: cycle_status_filter,
boolean_filters: boolean_filters,
group_filters: group_filters,
fee_type_filters: fee_type_filters
}},
socket
)
end
def handle_info({:reset_all_filters, %{} = opts}, socket) do
socket =
socket
|> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter))
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
socket =
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 -> Formatter.format_custom_field_value(cfv.value, custom_field)
end
end
}
end)
assign(socket, :dynamic_cols, dynamic_cols)
end
# -------------------------------------------------------------
# Sorting
# -------------------------------------------------------------
defp determine_new_sort(field, socket) do
if socket.assigns.sort_field == field do
{field, toggle_order(socket.assigns.sort_order)}
else
{field, :asc}
end
end
defp update_sort_components(socket, old_field, new_field, new_order) do
active_id = to_sort_id(new_field)
old_id = to_sort_id(old_field)
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
sort_order: new_order
)
socket
end
defp to_sort_id(field) when is_binary(field) do
String.to_existing_atom("sort_#{field}")
rescue
ArgumentError -> :"sort_#{field}"
end
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
# Only keep `fields` in the URL when it was already present (bookmark/share),
# OR when we intentionally push it via push_field_selection_url/1.
defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
fields_param = FieldSelection.to_url_param(selection)
if fields_param == "" do
Map.delete(params, "fields")
else
Map.put(params, "fields", fields_param)
end
end
defp maybe_add_field_selection(params, _selection, _include?), do: params
defp push_field_selection_url(socket) do
query_params =
build_query_params(opts_for_query_params(socket))
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
new_path = ~p"/members?#{query_params}"
push_reload(socket, new_path)
end
# 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 E-Mail 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. Vorname/Nachname/E-Mail,
# §7b). The email visibility carries over between the two representations,
# analogously to the "include email" sub-toggle: composite + include-email ⇄ the
# E-Mail 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 "Adresse" vs. the
# separate Straße/Hausnummer/PLZ/Ort 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 E-Mail 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 Vorname/Nachname and
# carries the prior email visibility onto the E-Mail 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(socket.assigns.show_current_cycle)
|> Ash.Query.load(groups: [:id, :name, :slug])
|> maybe_load_fee_type(socket)
end
defp maybe_load_fee_type(query, socket) do
if :membership_fee_type in socket.assigns.member_fields_visible or
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
Ash.Query.load(query, membership_fee_type: [:id, :name])
else
query
end
end
# Assembles the filter/sort options OverviewQuery understands from the current
# LiveView assigns.
defp overview_query_opts(socket) do
%{
search: socket.assigns.query,
group_filters: socket.assigns[:group_filters],
groups: socket.assigns[:groups],
fee_type_filters: socket.assigns[:fee_type_filters],
fee_types: socket.assigns[:fee_types],
boolean_custom_field_filters: socket.assigns.boolean_custom_field_filters,
boolean_custom_fields: socket.assigns.boolean_custom_fields,
date_filters: socket.assigns.date_filters,
date_custom_fields: socket.assigns[:date_custom_fields],
cycle_status_filter: socket.assigns.cycle_status_filter,
show_current_cycle: socket.assigns.show_current_cycle,
sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order,
custom_fields: socket.assigns.all_custom_fields
}
end
# Collects every custom field UUID whose values must be loaded for a given
# render — visible columns plus any active boolean or date filter. Kept as a
# standalone helper so load_members/1 stays under the credo complexity bar.
defp compute_ids_to_load(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
active_boolean_filter_ids =
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
date_custom_fields = socket.assigns[:date_custom_fields] || []
active_date_filter_ids =
DateFilter.active_custom_field_ids(
socket.assigns.date_filters,
date_custom_fields
)
(visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids)
|> Enum.uniq()
end
defp load_custom_field_values(query, []), do: query
defp load_custom_field_values(query, custom_field_ids) do
custom_field_values_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
query
|> Ash.Query.load(custom_field_values: custom_field_values_query)
end
# -------------------------------------------------------------
# Helper Functions
# -------------------------------------------------------------
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp toggle_order(nil), do: :asc
defp valid_sort_field?(field) when is_atom(field) do
# :groups is in computed_member_fields() but can be sorted
# Only :membership_fee_status should be blocked
if field == :membership_fee_status do
false
else
valid_sort_field_db_or_custom?(field)
end
end
defp valid_sort_field?(field) when is_binary(field) do
# "groups" is in computed_member_fields() but can be sorted
# Only "membership_fee_status" should be blocked
if field == "membership_fee_status" do
false
else
valid_sort_field_db_or_custom?(field)
end
end
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or
field in [:groups, :membership_fee_type, :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 custom_field_sort?(_), do: false
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, @custom_field_prefix) do
["", id] -> id
_ -> nil
end
end)
|> Enum.filter(&(&1 != nil))
end
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
old_field = socket.assigns.sort_field
socket
|> assign(:sort_field, field)
|> assign(:sort_order, order)
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|> assign(:previous_sort_field, old_field)
end
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, ""), do: default
defp determine_field(default, nil), do: default
# 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
def get_boolean_custom_field_value(member, custom_field) do
case get_custom_field_value(member, custom_field) do
nil -> nil
cfv -> extract_boolean_value(cfv.value)
end
end
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}),
do: extract_boolean_value(value)
defp extract_boolean_value(value) when is_map(value) do
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
if type == "boolean" or type == :boolean do
extract_boolean_value(val)
else
nil
end
end
defp extract_boolean_value(value) when is_boolean(value), do: value
defp extract_boolean_value(nil), do: nil
defp extract_boolean_value(_), do: nil
def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
when map_size(filters) == 0 do
members
end
def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
valid_custom_field_ids =
all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> MapSet.new(fn cf -> to_string(cf.id) end)
valid_filters =
Enum.filter(filters, fn {custom_field_id_str, _value} ->
MapSet.member?(valid_custom_field_ids, custom_field_id_str)
end)
|> Enum.into(%{})
if map_size(valid_filters) == 0 do
members
else
Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end)
end
end
defp matches_all_filters?(member, filters) do
Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
matches_filter?(member, custom_field_id_str, filter_value)
end)
end
defp matches_filter?(member, custom_field_id_str, filter_value) do
case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do
nil -> false
cfv -> extract_boolean_value(cfv.value) == filter_value
end
end
def format_selected_member_emails(members, selected_members) do
members
|> Enum.filter(fn member ->
MapSet.member?(selected_members, member.id) && member.email && member.email != ""
end)
|> Enum.map(&format_member_email/1)
end
def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id})
def format_member_email(member) do
first_name = member.first_name || ""
last_name = member.last_name || ""
name =
[first_name, last_name]
|> Enum.filter(&(&1 != ""))
|> Enum.join(" ")
if name == "", do: member.email, else: "#{name} <#{member.email}>"
end
def format_date(date), do: DateFormatter.format_date(date)
defp update_selection_assigns(socket) do
selected_members = socket.assigns.selected_members
# The selection may span members beyond the loaded page (after select-all),
# so its size is the MapSet size, not a count over the loaded window.
selected_count = MapSet.size(selected_members)
any_selected? = selected_count > 0
# Scope drives the trigger label: the selection when present, otherwise the
# whole list (filtered, when a search term or any filter is active).
scope =
cond do
any_selected? -> :selection
filters_active?(socket.assigns) -> :filtered
true -> :all
end
# No DB work here: the mailto recipient list is computed lazily when the
# bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or
# filter change never reads members for a link the user may never open.
socket
|> assign(:selected_count, selected_count)
|> assign(:scope, scope)
|> assign_export_payload()
end
# Fetches the mailto BCC recipients for the current bulk scope. Called only when
# the bulk-actions dropdown opens (the mailto item, a native anchor, is rendered
# only then), keeping selection toggles free of DB reads.
defp assign_mailto_recipients(socket) do
selected_count = MapSet.size(socket.assigns.selected_members)
# Size of the whole bulk scope (§1.17): the selection, or the full filtered
# total — never just the loaded page.
scope_size = if selected_count > 0, do: selected_count, else: socket.assigns.total_count
cap = Mv.Constants.max_mailto_bulk_recipients()
mailto_disabled? = scope_size >= cap
# Mailto recipients are bounded by the cap; only re-query the (small) scope
# when it is within the cap, otherwise the link is disabled anyway.
recipient_emails = if mailto_disabled?, do: [], else: scope_emails(socket, limit: cap)
recipient_count = if mailto_disabled?, do: scope_size, else: length(recipient_emails)
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
mailto_bcc =
recipient_emails
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
socket
|> assign(:recipient_count, recipient_count)
|> assign(:mailto_disabled?, mailto_disabled?)
|> assign(:mailto_bcc, mailto_bcc)
end
# Formatted "Name <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 "(gefiltert)" vs "(alle)"
trigger label and reads only assigns — no DB access.
"""
def filters_active?(assigns) do
search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns)
end
defp search_active?(assigns) do
query = assigns[:query]
is_binary(query) and query != ""
end
defp selection_filters_active?(assigns) do
not is_nil(assigns[:cycle_status_filter]) or
map_size(assigns[:group_filters] || %{}) > 0 or
map_size(assigns[:fee_type_filters] || %{}) > 0 or
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
end
defp date_filter_active?(assigns) do
(assigns[:date_filters] || DateFilter.default()) != DateFilter.default()
end
defp assign_export_payload(socket) do
payload = build_export_payload(socket)
assign(socket, :export_payload_json, Jason.encode!(payload))
end
defp build_export_payload(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
member_fields_db = socket.assigns[:member_fields_visible_db] || []
member_fields_computed = socket.assigns[:member_fields_visible_computed] || []
# Order DB member fields exactly like the table/constants
ordered_member_fields_db =
Mv.Constants.member_fields()
|> Enum.filter(&(&1 in member_fields_db))
# Order computed fields in canonical order
ordered_computed_fields =
FieldVisibility.computed_member_fields()
|> Enum.filter(&(&1 in member_fields_computed))
member_fields_with_groups =
build_export_member_fields_list(
ordered_member_fields_db,
socket.assigns[:member_fields_visible]
)
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
ordered_custom_field_ids =
socket.assigns.all_custom_fields
|> Enum.map(&to_string(&1.id))
|> Enum.filter(&(&1 in visible_custom_field_ids))
%{
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
member_fields:
Enum.map(member_fields_with_groups, fn
f when is_atom(f) -> Atom.to_string(f)
f when is_binary(f) -> f
end),
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
custom_field_ids: ordered_custom_field_ids,
column_order:
export_column_order(
ordered_member_fields_db,
ordered_computed_fields,
ordered_custom_field_ids,
:membership_fee_type in socket.assigns[:member_fields_visible],
:groups in socket.assigns[:member_fields_visible]
),
query: socket.assigns[:query] || nil,
sort_field: export_sort_field(socket.assigns[:sort_field]),
sort_order: export_sort_order(socket.assigns[:sort_order]),
show_current_cycle: socket.assigns[:show_current_cycle] || false,
cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]),
boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{}
}
end
defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
if f == "membership_fee_start_date" do
extra =
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
[f] ++ extra
else
[f]
end
end
defp build_export_member_fields_list(ordered_db, member_fields_visible) do
with_extras =
Enum.flat_map(ordered_db, fn f ->
if f == :membership_fee_start_date and
:membership_fee_type in (member_fields_visible || []) do
[f, :membership_fee_type]
else
[f]
end
end)
# If fee type is visible but start_date was not in the list, append it
with_extras =
if :membership_fee_type in (member_fields_visible || []) and
:membership_fee_type not in with_extras do
with_extras ++ [:membership_fee_type]
else
with_extras
end
if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
end
defp export_cycle_status_filter(nil), do: nil
defp export_cycle_status_filter(:paid), do: "paid"
defp export_cycle_status_filter(:unpaid), do: "unpaid"
defp export_cycle_status_filter(_), do: nil
defp export_sort_field(nil), do: nil
defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f)
defp export_sort_field(f) when is_binary(f), do: f
defp export_sort_order(nil), do: nil
defp export_sort_order(:asc), do: "asc"
defp export_sort_order(:desc), do: "desc"
defp export_sort_order(o) when is_binary(o), do: o
# Build a single ordered list that matches the table order:
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
# - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
# - groups appended before custom fields when visible
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
defp export_column_order(
ordered_member_fields_db,
ordered_computed_fields,
ordered_custom_field_ids,
membership_fee_type_visible,
groups_visible
) do
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
db_with_extras =
Enum.flat_map(
db_strings,
&expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
)
# If fee type is visible but start_date was not in the list, append it before computed/groups
db_with_extras =
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
db_with_extras ++ ["membership_fee_type"]
else
db_with_extras
end
# Any remaining computed fields not inserted above (future-proof)
remaining_computed =
computed_strings
|> Enum.reject(&(&1 in db_with_extras))
result = db_with_extras ++ remaining_computed
result = if groups_visible, do: result ++ ["groups"], else: result
result ++ ordered_custom_field_ids
end
end