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

1444 lines
45 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MvWeb.MemberLive.Index do
@moduledoc """
LiveView for displaying and managing the member list.
## Features
- Full-text search across member profiles using PostgreSQL tsvector
- Sortable columns (name, email, address fields)
- Bulk selection for future batch operations
- Real-time updates via LiveView
- Bookmarkable URLs with query parameters
## URL Parameters
- `query` - Search query string for full-text search
- `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
- `sort_order` - Sort direction (:asc or :desc)
## Events
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
- Sort state is synced with URL for bookmarkability
- Components communicate via `handle_info` for decoupling
"""
use MvWeb, :live_view
require Ash.Query
require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@custom_field_prefix Mv.Constants.custom_field_prefix()
@boolean_filter_prefix Mv.Constants.boolean_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)
custom_fields_visible =
Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# 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)
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
{:ok, s} -> s
# Fallback if settings can't be loaded
{:error, _} -> %{member_field_visibility: %{}}
end
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)
# FIX: ensure dropdown doesnt show duplicate fields (e.g. membership fee status twice)
all_available_fields =
all_custom_fields
|> FieldVisibility.get_all_available_fields()
|> dedupe_available_fields()
initial_selection =
FieldVisibility.merge_with_global_settings(
session_selection,
settings,
all_custom_fields
)
socket =
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> 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(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> 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_export_payload()
{:ok, socket}
end
# -----------------------------------------------------------------
# Handle Events
# -----------------------------------------------------------------
@doc """
Handles member-related UI events.
## Supported events:
- `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket)
case Ash.get(Mv.Membership.Member, id, actor: actor) do
{:ok, member} ->
case Ash.destroy(member, actor: actor) do
:ok ->
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply,
socket
|> assign(:members, updated_members)
|> put_flash(:info, gettext("Member deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
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)
|> update_selection_assigns()}
end
@impl true
def handle_event("select_all", _params, socket) do
all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
selected =
if MapSet.equal?(socket.assigns.selected_members, all_ids) do
MapSet.new()
else
all_ids
end
{:noreply,
socket
|> assign(:selected_members, selected)
|> 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()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
new_show_current,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
# Filter members that are in the selection and have email addresses
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
email_count = length(formatted_emails)
cond do
MapSet.size(selected_ids) == 0 ->
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
email_count == 0 ->
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
true ->
# 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
# Helper to format errors for display
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn error ->
case error do
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(error)
end
end)
Enum.join(error_messages, ", ")
end
defp format_error(error), do: inspect(error)
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@doc """
Handles messages from child components.
## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({:sort, field_str}, socket) do
# Handle both atom and string field names (for custom fields)
field =
try do
String.to_existing_atom(field_str)
rescue
ArgumentError -> field_str
end
{new_field, new_order} = determine_new_sort(field, socket)
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()
|> update_selection_assigns()
# URL sync
query_params =
build_query_params(
socket.assigns.query,
export_sort_field(socket.assigns.sort_field),
export_sort_order(socket.assigns.sort_order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end
@impl true
def handle_info({:search_changed, q}, socket) do
socket =
socket
|> assign(:query, q)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(
q,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:cycle_status_filter, filter)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
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()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
updated_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
socket =
socket
|> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
cycle_status_filter,
socket.assigns.show_current_cycle,
boolean_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
socket = update_session_field_selection(socket, new_selection)
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
{:noreply, socket}
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
socket = update_session_field_selection(socket, selection)
final_selection =
FieldVisibility.merge_with_global_settings(
selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
{:noreply, socket}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@impl true
def handle_params(params, _url, socket) do
prev_sig = build_signature(socket)
url_selection = FieldSelection.parse_from_url(params)
merged_selection =
FieldSelection.merge_sources(
url_selection,
socket.assigns.user_field_selection,
%{}
)
final_selection =
FieldVisibility.merge_with_global_settings(
merged_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
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
{: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.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || []
}
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
try do
String.to_existing_atom("sort_#{field}")
rescue
ArgumentError -> :"sort_#{field}"
end
end
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
defp push_sort_url(socket, field, order) do
field_str =
if is_atom(field) do
Atom.to_string(field)
else
field
end
query_params =
build_query_params(
socket.assigns.query,
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
defp maybe_add_field_selection(params, nil), do: params
defp maybe_add_field_selection(params, selection) when is_map(selection) do
fields_param = FieldSelection.to_url_param(selection)
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
end
defp maybe_add_field_selection(params, _), do: params
defp push_field_selection_url(socket) do
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true)
end
defp update_session_field_selection(socket, selection) do
assign(socket, :user_field_selection, selection)
end
defp build_query_params(
query,
sort_field,
sort_order,
cycle_status_filter,
show_current_cycle,
boolean_filters
) do
field_str =
if is_atom(sort_field) do
Atom.to_string(sort_field)
else
sort_field
end
order_str =
if is_atom(sort_order) do
Atom.to_string(sort_order)
else
sort_order
end
base_params = %{
"query" => query,
"sort_field" => field_str,
"sort_order" => order_str
}
base_params =
case cycle_status_filter do
nil -> base_params
:paid -> Map.put(base_params, "cycle_status_filter", "paid")
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
end
base_params =
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
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)
end
# -------------------------------------------------------------
# Loading members
# -------------------------------------------------------------
defp load_members(socket) do
search_query = socket.assigns.query
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
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)
ids_to_load =
(visible_custom_field_ids ++ active_boolean_filter_ids)
|> Enum.uniq()
query = load_custom_field_values(query, ids_to_load)
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
query = apply_search_filter(query, search_query)
# Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields
{query, sort_after_load} =
maybe_sort(
query,
socket.assigns.sort_field,
socket.assigns.sort_order,
custom_fields_for_sort
)
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
# Apply cycle status filter if set
members =
apply_cycle_status_filter(
members,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
# Apply boolean custom field filters if set
members =
apply_boolean_custom_field_filters(
members,
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
# Sort in memory if needed (custom fields only; computed fields are blocked)
members =
if sort_after_load and
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
sort_members_in_memory(
members,
socket.assigns.sort_field,
socket.assigns.sort_order,
custom_fields_for_sort
)
else
members
end
assign(socket, :members, members)
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 apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> Mv.Membership.Member.fuzzy_search(%{query: search_query})
else
query
end
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current)
when status in [:paid, :unpaid] do
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
end
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp toggle_order(nil), do: :asc
# Function to sort the column if needed.
# Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash.
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory.
defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false}
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
defp maybe_sort(query, field, order, _custom_fields) do
computed_atoms = FieldVisibility.computed_member_fields()
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
cond do
# Block computed fields (atom and string variants)
(is_atom(field) and field in computed_atoms) or
(is_binary(field) and field in computed_strings) ->
{query, false}
# Custom field sort -> after load
custom_field_sort?(field) ->
{query, true}
# DB field sort (atom)
is_atom(field) ->
{Ash.Query.sort(query, [{field, order}]), false}
# DB field sort (string) -> convert only if allowed
is_binary(field) ->
case safe_member_field_atom_only(field) do
nil -> {query, false}
atom -> {Ash.Query.sort(query, [{atom, order}]), false}
end
true ->
{query, false}
end
end
defp valid_sort_field?(field) when is_atom(field) do
if field in FieldVisibility.computed_member_fields(),
do: false,
else: valid_sort_field_db_or_custom?(field)
end
defp valid_sort_field?(field) when is_binary(field) do
if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
false
else
valid_sort_field_db_or_custom?(field)
end
end
defp valid_sort_field?(_), do: false
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)
end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
custom_field_sort?(field) or
((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom))
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_id(field) when is_atom(field) do
field |> Atom.to_string() |> extract_custom_field_id()
end
defp extract_custom_field_id(field) when is_binary(field) do
case String.split(field, @custom_field_prefix) do
["", id_str] -> id_str
_ -> nil
end
end
defp extract_custom_field_id(_), do: nil
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 sort_members_in_memory(members, field, order, custom_fields) do
custom_field_id_str = extract_custom_field_id(field)
case custom_field_id_str do
nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
custom_field = find_custom_field_by_id(custom_fields, id_str)
case custom_field do
nil -> members
cf -> sort_members_with_custom_field(members, cf, order)
end
end
defp find_custom_field_by_id(custom_fields, id_str) do
Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
end
defp sort_members_with_custom_field(members, custom_field, order) do
{members_with_values, members_without_values} =
split_members_by_value_presence(members, custom_field)
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
sorted_with_values ++ members_without_values
end
defp split_members_by_value_presence(members, custom_field) do
Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end)
end
defp has_non_empty_value?(member, custom_field) do
case get_custom_field_value(member, custom_field) do
nil ->
false
cfv ->
extracted = extract_sort_value(cfv.value, custom_field.value_type)
not empty_value?(extracted, custom_field.value_type)
end
end
defp sort_members_with_values(members_with_values, custom_field, order) do
sorted =
Enum.sort_by(members_with_values, fn member ->
cfv = get_custom_field_value(member, custom_field)
extracted = extract_sort_value(cfv.value, custom_field.value_type)
normalize_sort_value(extracted, order)
end)
if order == :desc, do: Enum.reverse(sorted), else: sorted
end
defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type),
do: extract_sort_value(value, type)
defp extract_sort_value(value, :string) when is_binary(value), do: value
defp extract_sort_value(value, :integer) when is_integer(value), do: value
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
defp extract_sort_value(%Date{} = date, :date), do: date
defp extract_sort_value(value, :email) when is_binary(value), do: value
defp extract_sort_value(value, _type), do: to_string(value)
defp empty_value?(value, :string) when is_binary(value), do: String.trim(value) == ""
defp empty_value?(value, :email) when is_binary(value), do: String.trim(value) == ""
defp empty_value?(_value, _type), do: false
defp normalize_sort_value(value, _order), do: value
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
socket
|> assign(:sort_field, field)
|> assign(:sort_order, order)
end
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, ""), do: default
defp determine_field(default, nil), do: default
defp determine_field(default, sf) when is_binary(sf) do
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
defp determine_field(default, sf) when is_atom(sf) do
if sf in FieldVisibility.computed_member_fields(),
do: default,
else: determine_field_after_computed_check(default, sf)
end
defp determine_field(default, _), do: default
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
if custom_field_sort?(sf) do
if valid_sort_field?(sf), do: sf, else: default
else
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 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
# -------------------------------------------------------------
# Custom Field Value Helpers
# -------------------------------------------------------------
def get_custom_field_value(member, custom_field) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
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 find_custom_field_value_by_id(member, custom_field_id_str) do
nil -> false
cfv -> extract_boolean_value(cfv.value) == filter_value
end
end
defp find_custom_field_value_by_id(member, custom_field_id_str) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == custom_field_id_str or
(match?(%{custom_field: %{id: _}}, cfv) &&
to_string(cfv.custom_field.id) == custom_field_id_str)
end)
_ ->
nil
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
members = socket.assigns[:members] || []
selected_members = socket.assigns.selected_members
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
mailto_bcc =
if any_selected? do
format_selected_member_emails(members, selected_members)
|> Enum.join(", ")
|> URI.encode_www_form()
else
""
end
socket
|> assign(:selected_count, selected_count)
|> assign(:any_selected?, any_selected?)
|> assign(:mailto_bcc, mailto_bcc)
|> assign_export_payload()
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))
# 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(ordered_member_fields_db, &Atom.to_string/1),
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
custom_field_ids: ordered_custom_field_ids,
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 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
# -------------------------------------------------------------
# Internal utility: dedupe dropdown fields defensively
# -------------------------------------------------------------
defp dedupe_available_fields(fields) when is_list(fields) do
Enum.uniq_by(fields, fn item ->
cond do
is_map(item) ->
Map.get(item, :key) || Map.get(item, :id) || Map.get(item, :field) || item
is_tuple(item) and tuple_size(item) >= 1 ->
elem(item, 0)
true ->
item
end
end)
end
defp dedupe_available_fields(other), do: other
end