1729 lines
57 KiB
Elixir
1729 lines
57 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
|
||
- `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
|
||
|
||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||
|
||
# Prefix used for boolean custom field filter URL parameters (e.g., "bf_<id>")
|
||
@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)
|
||
|
||
# Get all available fields (for dropdown - includes ALL custom fields)
|
||
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
|
||
|
||
# Merge session selection with global settings for initial state (use all_custom_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(:show_current_cycle, false)
|
||
|> assign(:membership_fee_status_filter, nil)
|
||
|
||
# We call handle params to use the query from the URL
|
||
{: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()
|
||
|
||
# Update URL to reflect cycle view change
|
||
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)
|
||
end
|
||
|
||
# -----------------------------------------------------------------
|
||
# 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)
|
||
|
||
socket
|
||
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|
||
|> push_sort_url(new_field, new_order)
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:search_changed, q}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:query, q)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
existing_field_query = socket.assigns.sort_field
|
||
existing_sort_query = socket.assigns.sort_order
|
||
|
||
# Build the URL with queries
|
||
query_params =
|
||
build_query_params(
|
||
q,
|
||
existing_field_query,
|
||
existing_sort_query,
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters
|
||
)
|
||
|
||
# Set the new path with params
|
||
new_path = ~p"/members?#{query_params}"
|
||
|
||
# Push the new URL
|
||
{: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()
|
||
|
||
# Build the URL with all params including new filter
|
||
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
|
||
# Update boolean filters map
|
||
updated_filters =
|
||
if filter_value == nil do
|
||
# Remove filter if nil (All option selected)
|
||
Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
|
||
else
|
||
# Add or update filter
|
||
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()
|
||
|
||
# Build the URL with all params including new filter
|
||
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
|
||
# Reset all filters at once (performance optimization)
|
||
# This avoids N×2 load_members() calls when resetting multiple filters
|
||
socket =
|
||
socket
|
||
|> assign(:cycle_status_filter, cycle_status_filter)
|
||
|> assign(:boolean_custom_field_filters, boolean_filters)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
# Build the URL with all params including reset filters
|
||
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
|
||
# Update user field selection
|
||
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
||
|
||
# Save to session (cookie will be saved on next page load via handle_params)
|
||
socket = update_session_field_selection(socket, new_selection)
|
||
|
||
# Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
new_selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
# Get visible fields
|
||
visible_member_fields = FieldVisibility.get_visible_member_fields(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(: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
|
||
# Save to session
|
||
socket = update_session_field_selection(socket, selection)
|
||
|
||
# Merge with global settings (use all_custom_fields for merging)
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
# Get visible fields
|
||
visible_member_fields = FieldVisibility.get_visible_member_fields(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(: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
|
||
# -----------------------------------------------------------------
|
||
@doc """
|
||
Handles URL parameter changes.
|
||
|
||
Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
|
||
then loads members accordingly. This enables bookmarkable URLs and
|
||
browser back/forward navigation.
|
||
"""
|
||
@impl true
|
||
def handle_params(params, _url, socket) do
|
||
# Build signature BEFORE updates to detect if anything actually changed
|
||
prev_sig = build_signature(socket)
|
||
|
||
# Parse field selection from URL
|
||
url_selection = FieldSelection.parse_from_url(params)
|
||
|
||
# Merge with session selection (URL has priority)
|
||
merged_selection =
|
||
FieldSelection.merge_sources(
|
||
url_selection,
|
||
socket.assigns.user_field_selection,
|
||
%{}
|
||
)
|
||
|
||
# Merge with global settings (use all_custom_fields for merging)
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
merged_selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
# Get visible fields
|
||
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||
|
||
# Apply all updates
|
||
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(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||
|
||
# Build signature AFTER updates
|
||
next_sig = build_signature(socket)
|
||
|
||
# Only load members if signature changed (optimization: avoid duplicate loads)
|
||
# OR if members haven't been loaded yet (first handle_params call after mount)
|
||
socket =
|
||
if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
|
||
# Nothing changed AND members already loaded, skip expensive load_members() call
|
||
socket
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
else
|
||
# Signature changed OR members not loaded yet, reload members
|
||
socket
|
||
|> load_members()
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
end
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
# Builds a signature tuple representing all filter/sort parameters that affect member loading.
|
||
#
|
||
# This signature is used to detect if member data needs to be reloaded when handle_params
|
||
# is called. If the signature hasn't changed, we can skip the expensive load_members() call.
|
||
#
|
||
# Returns a tuple containing all relevant parameters:
|
||
# - query: Search query string
|
||
# - sort_field: Field to sort by
|
||
# - sort_order: Sort direction (:asc or :desc)
|
||
# - cycle_status_filter: Payment filter (:paid, :unpaid, or nil)
|
||
# - show_current_cycle: Whether to show current cycle
|
||
# - boolean_custom_field_filters: Map of active boolean filters
|
||
# - user_field_selection: Map of user's field visibility selections
|
||
# - visible_custom_field_ids: List of visible custom field IDs (affects which custom fields are loaded)
|
||
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
|
||
|
||
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
|
||
#
|
||
# Creates a list of column definitions, each containing:
|
||
# - `:custom_field` - The CustomField resource
|
||
# - `:render` - A function that formats the custom field value for a given member
|
||
#
|
||
# Only includes custom fields that are visible according to user field selection.
|
||
#
|
||
# Returns the socket with `:dynamic_cols` assigned.
|
||
defp prepare_dynamic_cols(socket) do
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
|
||
# Use all_custom_fields to allow users to enable globally hidden custom fields
|
||
dynamic_cols =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids 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
|
||
|
||
# -------------------------------------------------------------
|
||
# FUNCTIONS
|
||
# -------------------------------------------------------------
|
||
|
||
# Determines new sort field and order based on current state
|
||
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
|
||
|
||
# Updates both the active and old SortHeader components
|
||
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)
|
||
|
||
# Update the new SortHeader
|
||
send_update(MvWeb.Components.SortHeaderComponent,
|
||
id: active_id,
|
||
sort_field: new_field,
|
||
sort_order: new_order
|
||
)
|
||
|
||
# Reset the current SortHeader
|
||
send_update(MvWeb.Components.SortHeaderComponent,
|
||
id: old_id,
|
||
sort_field: new_field,
|
||
sort_order: new_order
|
||
)
|
||
|
||
socket
|
||
end
|
||
|
||
# Converts a field (atom or string) to a sort component ID atom
|
||
# Handles both existing atoms and strings that need to be converted
|
||
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}"
|
||
end
|
||
|
||
# Builds sort URL and pushes navigation patch
|
||
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
|
||
|
||
# Adds field selection to query params if present
|
||
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
|
||
|
||
# Pushes URL with updated field selection
|
||
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
|
||
|
||
# Updates session field selection (stored in socket for now, actual session update via controller)
|
||
defp update_session_field_selection(socket, selection) do
|
||
# Store in socket for now - actual session persistence would require a controller
|
||
# This is a placeholder for future session persistence
|
||
assign(socket, :user_field_selection, selection)
|
||
end
|
||
|
||
# Builds URL query parameters map including all filter/sort state.
|
||
# Converts cycle_status_filter atom to string for URL.
|
||
# Adds boolean custom field filters as bf_<id>=true|false.
|
||
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
|
||
}
|
||
|
||
# Only add cycle_status_filter to URL if it's set
|
||
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
|
||
|
||
# Add show_current_cycle if true
|
||
base_params =
|
||
if show_current_cycle do
|
||
Map.put(base_params, "show_current_cycle", "true")
|
||
else
|
||
base_params
|
||
end
|
||
|
||
# Add boolean custom field filters
|
||
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
|
||
|
||
# Loads members from the database with custom field values and applies search/sort/payment filters.
|
||
#
|
||
# Process:
|
||
# 1. Builds base query with selected fields
|
||
# 2. Loads custom field values for visible custom fields (filtered at database level)
|
||
# 3. Applies search filter if provided
|
||
# 4. Applies payment status filter if set
|
||
# 5. Applies sorting (database-level for regular fields, in-memory for custom fields)
|
||
#
|
||
# Performance Considerations:
|
||
# - Database-level filtering: Custom field values are filtered directly in the database
|
||
# using Ash relationship filters, reducing memory usage and improving performance.
|
||
# - In-memory sorting: Custom field sorting is done in memory after loading.
|
||
# This is suitable for small to medium datasets (<1000 members).
|
||
# For larger datasets, consider implementing database-level sorting or pagination.
|
||
# - No pagination: All matching members are loaded at once. For large result sets,
|
||
# consider implementing pagination (see Issue #165).
|
||
#
|
||
# Returns the socket with `:members` assigned.
|
||
defp load_members(socket) do
|
||
search_query = socket.assigns.query
|
||
|
||
query =
|
||
Mv.Membership.Member
|
||
|> Ash.Query.new()
|
||
|> Ash.Query.select(@overview_fields)
|
||
|
||
# Load custom field values for visible custom fields AND active boolean filters
|
||
# This ensures boolean filters work even when the custom field is not visible in overview
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
|
||
# Get IDs of active boolean filters (whitelisted against boolean_custom_fields)
|
||
# Convert boolean_custom_fields list to map for efficient lookup (consistent with maybe_update_boolean_filters)
|
||
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 ->
|
||
# Validate UUID format and check against whitelist
|
||
String.length(id_str) <= @max_uuid_length &&
|
||
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
|
||
Map.has_key?(boolean_custom_fields_map, id_str)
|
||
end)
|
||
|
||
# Union of visible IDs and active filter IDs
|
||
ids_to_load =
|
||
(visible_custom_field_ids ++ active_boolean_filter_ids)
|
||
|> Enum.uniq()
|
||
|
||
query = load_custom_field_values(query, ids_to_load)
|
||
|
||
# Load membership fee cycles for status display
|
||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||
|
||
# Apply the search filter first
|
||
query = apply_search_filter(query, search_query)
|
||
|
||
# Apply sorting based on current socket state
|
||
# For custom fields, we sort after loading
|
||
{query, sort_after_load} =
|
||
maybe_sort(
|
||
query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.custom_fields_visible
|
||
)
|
||
|
||
# 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)
|
||
time_milliseconds = time_microseconds / 1000
|
||
Logger.info("Ash.read! in load_members/1 took #{time_milliseconds} 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 (for custom fields)
|
||
members =
|
||
if sort_after_load do
|
||
sort_members_in_memory(
|
||
members,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.custom_fields_visible
|
||
)
|
||
else
|
||
members
|
||
end
|
||
|
||
assign(socket, :members, members)
|
||
end
|
||
|
||
# Load custom field values for the given custom field IDs
|
||
#
|
||
# Filters custom field values directly in the database using Ash relationship filters.
|
||
# This is more efficient than loading all values and filtering in memory.
|
||
#
|
||
# Performance: Database-level filtering reduces:
|
||
# - Memory usage (only visible custom field values are loaded)
|
||
# - Network transfer (less data from database to application)
|
||
# - Processing time (no need to iterate through all members and filter)
|
||
defp load_custom_field_values(query, []) do
|
||
query
|
||
end
|
||
|
||
defp load_custom_field_values(query, custom_field_ids) do
|
||
# Filter custom field values at the database level using Ash relationship query
|
||
# This ensures only visible custom field values are loaded
|
||
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
|
||
# -------------------------------------------------------------
|
||
|
||
# Function to apply search query
|
||
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
|
||
|
||
# Applies cycle status filter to members list.
|
||
#
|
||
# Filter values:
|
||
# - nil: No filter, return all members
|
||
# - :paid: Only members with paid status in the selected cycle (last or current)
|
||
# - :unpaid: Only members with unpaid status in the selected cycle (last or current)
|
||
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
|
||
|
||
# Functions to toggle sorting order
|
||
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
|
||
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory
|
||
defp maybe_sort(query, nil, _, _), do: {query, false}
|
||
|
||
defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do
|
||
if custom_field_sort?(field) do
|
||
# Custom fields need to be sorted in memory after loading
|
||
{query, true}
|
||
else
|
||
# Only sort by atom fields (regular member fields) in database
|
||
if is_atom(field) do
|
||
{Ash.Query.sort(query, [{field, order}]), false}
|
||
else
|
||
{query, false}
|
||
end
|
||
end
|
||
end
|
||
|
||
defp maybe_sort(query, _, _, _), do: {query, false}
|
||
|
||
# Validate that a field is sortable
|
||
# Uses member fields from constants, but excludes fields that don't make sense to sort
|
||
# (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
|
||
defp valid_sort_field?(field) when is_atom(field) do
|
||
# All member fields are sortable, but we exclude some that don't make sense
|
||
# :id is not in member_fields, but we don't want to sort by it anyway
|
||
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?(field) when is_binary(field) do
|
||
custom_field_sort?(field)
|
||
end
|
||
|
||
defp valid_sort_field?(_), do: false
|
||
|
||
# Check if field is a custom field sort field (format: custom_field_<id>)
|
||
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
|
||
|
||
# Extracts the custom field ID from a sort field name.
|
||
#
|
||
# Sort fields for custom fields use the format: "custom_field_<id>"
|
||
# This function extracts the ID part.
|
||
#
|
||
# Examples:
|
||
# extract_custom_field_id("custom_field_123") -> "123"
|
||
# extract_custom_field_id(:custom_field_123) -> "123"
|
||
# extract_custom_field_id("first_name") -> nil
|
||
defp extract_custom_field_id(field) when is_atom(field) do
|
||
field_str = Atom.to_string(field)
|
||
extract_custom_field_id(field_str)
|
||
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
|
||
|
||
# Extracts custom field IDs from visible custom field strings
|
||
# Format: "custom_field_<id>" -> <id>
|
||
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
|
||
|
||
# Sorts members in memory by a custom field value.
|
||
#
|
||
# Process:
|
||
# 1. Extracts custom field ID from sort field name
|
||
# 2. Finds the corresponding CustomField resource
|
||
# 3. Splits members into those with values and those without
|
||
# 4. Sorts members with values by the extracted value
|
||
# 5. Combines: sorted values first, then NULL/empty values at the end
|
||
#
|
||
# Performance Note:
|
||
# This function sorts in memory, which is suitable for small to medium datasets (<1000 members).
|
||
# For larger datasets, consider implementing database-level sorting or pagination.
|
||
#
|
||
# Parameters:
|
||
# - `members` - List of Member resources to sort
|
||
# - `field` - Sort field name (format: "custom_field_<id>" or atom)
|
||
# - `order` - Sort order (`:asc` or `:desc`)
|
||
# - `custom_fields` - List of visible CustomField resources
|
||
#
|
||
# Returns the sorted list of members.
|
||
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
|
||
|
||
# Sorts members by a specific custom field ID
|
||
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
|
||
|
||
# Finds a custom field by matching its ID string
|
||
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
|
||
|
||
# Sorts members that have a specific custom field
|
||
defp sort_members_with_custom_field(members, custom_field, order) do
|
||
# Split members into those with values and those without (NULL/empty)
|
||
{members_with_values, members_without_values} =
|
||
split_members_by_value_presence(members, custom_field)
|
||
|
||
# Sort members with values
|
||
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
|
||
|
||
# Combine: sorted values first, then NULL/empty values at the end
|
||
sorted_with_values ++ members_without_values
|
||
end
|
||
|
||
# Splits members into those with values and those without
|
||
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
|
||
|
||
# Checks if a member has a non-empty value for the custom field
|
||
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
|
||
|
||
# Sorts members that have values for the custom field
|
||
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)
|
||
|
||
# For DESC, reverse only the members with values
|
||
if order == :desc do
|
||
Enum.reverse(sorted)
|
||
else
|
||
sorted
|
||
end
|
||
end
|
||
|
||
# Extracts a sortable value from a custom field value based on its type.
|
||
#
|
||
# Handles different value formats:
|
||
# - `%Ash.Union{}` - Extracts value and type from union
|
||
# - Direct values - Returns as-is for primitive types
|
||
#
|
||
# Returns the extracted value suitable for sorting.
|
||
defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do
|
||
extract_sort_value(value, type)
|
||
end
|
||
|
||
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)
|
||
|
||
# Check if a value is considered empty (NULL or empty string)
|
||
defp empty_value?(value, :string) when is_binary(value) do
|
||
String.trim(value) == ""
|
||
end
|
||
|
||
defp empty_value?(value, :email) when is_binary(value) do
|
||
String.trim(value) == ""
|
||
end
|
||
|
||
defp empty_value?(_value, _type), do: false
|
||
|
||
# Normalize sort value for DESC order
|
||
# For DESC, we sort ascending first, then reverse the list
|
||
# This function is kept for consistency but doesn't need to invert values
|
||
defp normalize_sort_value(value, _order), do: value
|
||
|
||
# Updates sort field and order from URL parameters if present.
|
||
#
|
||
# Validates the sort field and order, falling back to defaults if invalid.
|
||
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
|
||
|
||
# Determine sort field from URL parameter, validating against allowed fields
|
||
defp determine_field(default, ""), do: default
|
||
defp determine_field(default, nil), do: default
|
||
|
||
# Determines the valid sort field from a URL parameter.
|
||
#
|
||
# Validates the field against allowed sort fields (regular member fields or custom fields).
|
||
# Falls back to default if the field is invalid.
|
||
#
|
||
# Parameters:
|
||
# - `default` - Default field to use if validation fails
|
||
# - `sf` - Sort field from URL (can be atom, string, nil, or empty string)
|
||
#
|
||
# Returns a valid sort field (atom or string for custom fields).
|
||
defp determine_field(default, sf) when is_binary(sf) do
|
||
# Check if it's a custom field sort (starts with "custom_field_")
|
||
if custom_field_sort?(sf) do
|
||
if valid_sort_field?(sf), do: sf, else: default
|
||
else
|
||
# Try to convert to atom for regular fields
|
||
try do
|
||
atom = String.to_existing_atom(sf)
|
||
if valid_sort_field?(atom), do: atom, else: default
|
||
rescue
|
||
ArgumentError -> default
|
||
end
|
||
end
|
||
end
|
||
|
||
defp determine_field(default, sf) when is_atom(sf) do
|
||
if valid_sort_field?(sf), do: sf, else: default
|
||
end
|
||
|
||
defp determine_field(default, _), do: default
|
||
|
||
# Determines the valid sort order from a URL parameter.
|
||
#
|
||
# Validates that the order is either "asc" or "desc", falling back to default if invalid.
|
||
#
|
||
# Parameters:
|
||
# - `default` - Default order to use if validation fails
|
||
# - `so` - Sort order from URL (string, atom, nil, or empty string)
|
||
#
|
||
# Returns `:asc` or `:desc`.
|
||
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
|
||
|
||
# Function to update search parameters
|
||
defp maybe_update_search(socket, %{"query" => query}) when query != "" do
|
||
assign(socket, :query, query)
|
||
end
|
||
|
||
defp maybe_update_search(socket, _params) do
|
||
# Keep the previous search query if no new one is provided
|
||
socket
|
||
end
|
||
|
||
# Updates cycle status filter from URL parameters if present.
|
||
#
|
||
# Validates the filter value, falling back to nil (no filter) if invalid.
|
||
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
|
||
# Reset filter if not in URL params
|
||
assign(socket, :cycle_status_filter, nil)
|
||
end
|
||
|
||
# Determines valid cycle status filter from URL parameter.
|
||
#
|
||
# SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid"
|
||
# are accepted - all other input (including malicious strings) falls back to nil.
|
||
# This ensures no raw user input is ever passed to filter functions.
|
||
defp determine_cycle_status_filter("paid"), do: :paid
|
||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||
defp determine_cycle_status_filter(_), do: nil
|
||
|
||
# Updates boolean custom field filters from URL parameters if present.
|
||
#
|
||
# Parses all URL parameters with prefix @boolean_filter_prefix and validates them:
|
||
# - Extracts custom field ID from parameter name (explicitly removes prefix)
|
||
# - Validates filter value using determine_boolean_filter/1
|
||
# - Whitelisting: Only custom field IDs that exist and have value_type: :boolean
|
||
# - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks
|
||
# - Security: Validates UUID length (max @max_uuid_length characters)
|
||
#
|
||
# Returns socket with updated :boolean_custom_field_filters assign.
|
||
defp maybe_update_boolean_filters(socket, params) do
|
||
# Get all boolean custom fields for whitelisting (keyed by ID as string for consistency)
|
||
boolean_custom_fields =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||
|
||
# Parse all boolean filter parameters
|
||
# Security: Use reduce_while to abort early after @max_boolean_filters to prevent DoS attacks
|
||
# This protects CPU/Parsing costs, not just memory/state
|
||
# We count processed parameters (not just valid filters) to protect against parsing DoS
|
||
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
|
||
)
|
||
|
||
# Increment counter for each processed parameter (DoS protection)
|
||
# Note: We count processed params, not just valid filters, to protect parsing costs
|
||
{:cont, {new_acc, count + 1}}
|
||
end
|
||
end)
|
||
|
||
# Log warning if we hit the limit
|
||
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
|
||
|
||
# Processes a single boolean filter parameter from URL params.
|
||
#
|
||
# Validates the parameter and adds it to the accumulator if valid.
|
||
# Returns the accumulator unchanged if validation fails.
|
||
defp process_boolean_filter_param(
|
||
key,
|
||
value_str,
|
||
prefix_length,
|
||
boolean_custom_fields,
|
||
acc
|
||
) do
|
||
# Extract custom field ID from parameter name (explicitly remove prefix)
|
||
# This is more secure than String.replace_prefix which only removes first occurrence
|
||
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
|
||
|
||
# Validate custom field ID length (UUIDs are max @max_uuid_length characters)
|
||
# This provides an additional security layer beyond UUID format validation
|
||
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
|
||
|
||
# Validates UUID format and custom field existence, then adds filter if valid.
|
||
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
|
||
|
||
# Adds boolean filter to accumulator if custom field exists and value is valid.
|
||
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
|
||
|
||
# Determines valid boolean filter value from URL parameter.
|
||
#
|
||
# SECURITY: This function whitelists allowed filter values. Only "true" and "false"
|
||
# are accepted - all other input (including malicious strings) falls back to nil.
|
||
# This ensures no raw user input is ever passed to filter functions.
|
||
#
|
||
# Returns:
|
||
# - `true` for "true" string
|
||
# - `false` for "false" string
|
||
# - `nil` for any other value
|
||
defp determine_boolean_filter("true"), do: true
|
||
defp determine_boolean_filter("false"), do: false
|
||
defp determine_boolean_filter(_), do: nil
|
||
|
||
# Updates show_current_cycle from URL parameters if present.
|
||
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
|
||
assign(socket, :show_current_cycle, true)
|
||
end
|
||
|
||
defp maybe_update_show_current_cycle(socket, _params) do
|
||
socket
|
||
end
|
||
|
||
# -------------------------------------------------------------
|
||
# Helper Functions for Custom Field Values
|
||
# -------------------------------------------------------------
|
||
|
||
# Retrieves the custom field value for a specific member and custom field.
|
||
#
|
||
# Searches through the member's `custom_field_values` relationship to find
|
||
# the value matching the given custom field.
|
||
#
|
||
# Returns:
|
||
# - `%CustomFieldValue{}` if found
|
||
# - `nil` if not found or if member has no custom field values
|
||
#
|
||
# Examples:
|
||
# get_custom_field_value(member, custom_field) -> %CustomFieldValue{...}
|
||
# get_custom_field_value(member, non_existent_field) -> nil
|
||
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
|
||
|
||
# Extracts the boolean value from a member's custom field value.
|
||
#
|
||
# Handles different value formats:
|
||
# - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union
|
||
# - Map format with `"type"` and `"value"` keys - Extracts from map
|
||
# - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map
|
||
#
|
||
# Returns:
|
||
# - `true` if the custom field value is boolean true
|
||
# - `false` if the custom field value is boolean false
|
||
# - `nil` if no custom field value exists, value is nil, or value is not boolean
|
||
#
|
||
# Examples:
|
||
# get_boolean_custom_field_value(member, boolean_field) -> true
|
||
# get_boolean_custom_field_value(member, non_existent_field) -> nil
|
||
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
|
||
|
||
# Extracts boolean value from custom field value, handling different formats.
|
||
#
|
||
# Handles:
|
||
# - `%Ash.Union{value: value, type: :boolean}` - Union struct format
|
||
# - Map with `"type"` and `"value"` keys - JSONB map format
|
||
# - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format
|
||
# - Direct boolean value - Primitive boolean
|
||
#
|
||
# Returns `true`, `false`, or `nil`.
|
||
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do
|
||
extract_boolean_value(value)
|
||
end
|
||
|
||
defp extract_boolean_value(value) when is_map(value) do
|
||
# Handle map format from JSONB
|
||
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
|
||
|
||
# Applies boolean custom field filters to a list of members.
|
||
#
|
||
# Filters members based on boolean custom field values. Only members that match
|
||
# ALL active filters (AND logic) are returned.
|
||
#
|
||
# Parameters:
|
||
# - `members` - List of Member resources with loaded custom_field_values
|
||
# - `filters` - Map of `%{custom_field_id_string => true | false}`
|
||
# - `all_custom_fields` - List of all CustomField resources (for validation)
|
||
#
|
||
# Returns:
|
||
# - Filtered list of members that match all active filters
|
||
# - All members if filters map is empty
|
||
# - Filters with non-existent custom field IDs are ignored
|
||
#
|
||
# Examples:
|
||
# apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...]
|
||
# apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members
|
||
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
|
||
# Build a map of valid boolean custom field IDs (as strings) for quick lookup
|
||
valid_custom_field_ids =
|
||
all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> MapSet.new(fn cf -> to_string(cf.id) end)
|
||
|
||
# Filter out invalid custom field IDs from filters
|
||
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 no valid filters remain, return all members
|
||
if map_size(valid_filters) == 0 do
|
||
members
|
||
else
|
||
Enum.filter(members, fn member ->
|
||
matches_all_filters?(member, valid_filters)
|
||
end)
|
||
end
|
||
end
|
||
|
||
# Checks if a member matches all active boolean filters.
|
||
#
|
||
# A member matches a filter if:
|
||
# - The filter value is `true` and the member's custom field value is `true`
|
||
# - The filter value is `false` and the member's custom field value is `false`
|
||
#
|
||
# Members without a custom field value or with `nil` value do not match any filter.
|
||
#
|
||
# Returns `true` if all filters match, `false` otherwise.
|
||
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
|
||
|
||
# Checks if a member matches a specific boolean filter.
|
||
#
|
||
# Finds the custom field value by ID and checks if the member's boolean value
|
||
# matches the filter value.
|
||
#
|
||
# Returns:
|
||
# - `true` if the member's boolean value matches the filter value
|
||
# - `false` if no custom field value exists (member is filtered out)
|
||
# - `false` if value is nil or values don't match
|
||
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 ->
|
||
boolean_value = extract_boolean_value(cfv.value)
|
||
boolean_value == filter_value
|
||
end
|
||
end
|
||
|
||
# Finds a custom field value by custom field ID string.
|
||
#
|
||
# Searches through the member's custom_field_values to find one matching
|
||
# the given custom field ID.
|
||
#
|
||
# Returns the CustomFieldValue or nil.
|
||
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
|
||
|
||
# Filters selected members with email addresses and formats them.
|
||
# Returns a list of formatted email strings in the format "First Last <email>".
|
||
# Used by both copy_emails and mailto links.
|
||
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
|
||
|
||
@doc """
|
||
Returns a JS command to toggle member selection when clicking the checkbox column.
|
||
|
||
Used as `col_click` handler to ensure clicking anywhere in the checkbox column
|
||
toggles the checkbox instead of navigating to the member details.
|
||
"""
|
||
def checkbox_column_click(member) do
|
||
JS.push("select_member", value: %{id: member.id})
|
||
end
|
||
|
||
# Formats a member's email in the format "First Last <email>"
|
||
# Used for copy_emails feature and mailto links to create email-client-friendly format.
|
||
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
|
||
end
|
||
|
||
# Public helper function to format dates for use in templates
|
||
def format_date(date), do: DateFormatter.format_date(date)
|
||
|
||
# Updates selection-related assigns (selected_count, any_selected?, mailto_bcc)
|
||
# to avoid recalculating Enum.any? and Enum.count multiple times in templates.
|
||
#
|
||
# Note: Mailto URLs have length limits that vary by email client.
|
||
# For large selections, consider using export functionality instead.
|
||
#
|
||
# Handles case where members haven't been loaded yet (e.g., when signature didn't change in handle_params).
|
||
defp update_selection_assigns(socket) do
|
||
# Handle case where members haven't been loaded yet (e.g., when signature didn't change)
|
||
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)
|
||
end
|
||
end
|