Merge branch 'main' into feature/335_csv_import_ui
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-01-23 17:55:23 +01:00
commit 9ddd1a470d
11 changed files with 2725 additions and 433 deletions

View file

@ -19,6 +19,12 @@ defmodule Mv.Constants do
@custom_field_prefix "custom_field_"
@boolean_filter_prefix "bf_"
@max_boolean_filters 50
@max_uuid_length 36
@email_validator_checks [:html_input, :pow]
def member_fields, do: @member_fields
@ -33,6 +39,42 @@ defmodule Mv.Constants do
"""
def custom_field_prefix, do: @custom_field_prefix
@doc """
Returns the prefix used for boolean custom field filter URL parameters.
## Examples
iex> Mv.Constants.boolean_filter_prefix()
"bf_"
"""
def boolean_filter_prefix, do: @boolean_filter_prefix
@doc """
Returns the maximum number of boolean custom field filters allowed per request.
This limit prevents DoS attacks by restricting the number of filter parameters
that can be processed in a single request.
## Examples
iex> Mv.Constants.max_boolean_filters()
50
"""
def max_boolean_filters, do: @max_boolean_filters
@doc """
Returns the maximum length of a UUID string (36 characters including hyphens).
UUIDs in standard format (e.g., "550e8400-e29b-41d4-a716-446655440000") are
exactly 36 characters long. This constant is used for input validation.
## Examples
iex> Mv.Constants.max_uuid_length()
36
"""
def max_uuid_length, do: @max_uuid_length
@doc """
Returns the email validator checks used for EctoCommons.EmailValidator.

View file

@ -0,0 +1,444 @@
defmodule MvWeb.Components.MemberFilterComponent do
@moduledoc """
Provides the MemberFilter Live-Component.
A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields.
Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters.
## Design Decisions
- Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles
(padding, display, hover, font sizes) that would interfere with form controls.
- Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
Uses semantic `<fieldset>` and `<legend>` for proper accessibility and form structure.
- Dropdown stays open when clicking filter segments to allow multiple filter changes.
- Uses `phx-change` on form for radio inputs instead of individual `phx-click` events.
## Props
- `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
- `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
tabindex="0"
class={[
"btn gap-2",
(@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter members")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">
{button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
</span>
<span
:if={active_boolean_filters_count(@boolean_filters) > 0}
class="badge badge-primary badge-sm"
>
{active_boolean_filters_count(@boolean_filters)}
</span>
<span
:if={@cycle_status_filter && active_boolean_filters_count(@boolean_filters) == 0}
class="badge badge-primary badge-sm"
>
{@member_count}
</span>
</button>
<!--
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
(padding, display, hover, font sizes) that would interfere with our form controls.
Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
We use semantic fieldset/legend structure for proper accessibility.
We use relative/absolute positioning instead of DaisyUI dropdown classes to have
full control over the open/close state via LiveView.
-->
<div
:if={@open}
tabindex="0"
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
phx-click-away="close_dropdown"
phx-target={@myself}
role="dialog"
aria-label={gettext("Member filter")}
>
<form phx-change="update_filters" phx-target={@myself}>
<!-- Payment Filter Group -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Payments")}
</div>
<fieldset class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-0 p-0 m-0 min-w-0">
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{gettext("Payment Status")}
</legend>
<div class="join col-start-2">
<label
class={"#{payment_filter_label_class(@cycle_status_filter, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-all"
>
<input
type="radio"
id="payment-filter-all"
name="payment_filter"
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{payment_filter_label_class(@cycle_status_filter, :paid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-paid"
>
<input
type="radio"
id="payment-filter-paid"
name="payment_filter"
value="paid"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == :paid}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Paid")}</span>
</label>
<label
class={"#{payment_filter_label_class(@cycle_status_filter, :unpaid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="payment-filter-unpaid"
>
<input
type="radio"
id="payment-filter-unpaid"
name="payment_filter"
value="unpaid"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={@cycle_status_filter == :unpaid}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Unpaid")}</span>
</label>
</div>
</fieldset>
</div>
<!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom Fields")}
</div>
<div class="max-h-60 overflow-y-auto pr-2">
<fieldset
:for={custom_field <- @boolean_custom_fields}
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
>
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{custom_field.name}
</legend>
<div class="join col-start-2">
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-all"}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-all"}
name={"custom_boolean[#{custom_field.id}]"}
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, true)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-true"}
aria-label={gettext("Yes")}
title={gettext("Yes")}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-true"}
name={"custom_boolean[#{custom_field.id}]"}
value="true"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == true}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Yes")}</span>
</label>
<label
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, false)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"custom-boolean-filter-#{custom_field.id}-false"}
aria-label={gettext("No")}
title={gettext("No")}
>
<input
type="radio"
id={"custom-boolean-filter-#{custom_field.id}-false"}
name={"custom_boolean[#{custom_field.id}]"}
value="false"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == false}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("No")}</span>
</label>
</div>
</fieldset>
</div>
</div>
<!-- Footer -->
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
<button
type="button"
phx-click="reset_filters"
phx-target={@myself}
class="btn btn-sm"
>
{gettext("Reset")}
</button>
<button
type="button"
phx-click="close_dropdown"
phx-target={@myself}
class="btn btn-primary btn-sm"
>
{gettext("Close")}
</button>
</div>
</form>
</div>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("update_filters", params, socket) do
# Parse payment filter
payment_filter =
case Map.get(params, "payment_filter") do
"paid" -> :paid
"unpaid" -> :unpaid
_ -> nil
end
# Parse boolean custom field filters (including nil values for "all")
custom_boolean_filters_parsed =
params
|> Map.get("custom_boolean", %{})
|> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc ->
filter_value = parse_tri_state(value_str)
Map.put(acc, custom_field_id_str, filter_value)
end)
# Update payment filter if changed
if payment_filter != socket.assigns.cycle_status_filter do
send(self(), {:payment_filter_changed, payment_filter})
end
# Update boolean filters - send events for each changed filter
current_filters = socket.assigns.boolean_filters
# Process all custom field filters from form (including those set to "all"/nil)
# Radio buttons in a group always send a value, so all active filters are in the form
Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} ->
current_value = Map.get(current_filters, custom_field_id_str)
# Only send event if value actually changed
if current_value != new_value do
send(self(), {:boolean_filter_changed, custom_field_id_str, new_value})
end
end)
# Don't close dropdown - allow multiple filter changes
{:noreply, socket}
end
@impl true
def handle_event("reset_filters", _params, socket) do
# Send single message to reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters
send(self(), {:reset_all_filters, nil, %{}})
# Close dropdown after reset
{:noreply, assign(socket, :open, false)}
end
# Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false
defp parse_tri_state("true"), do: true
defp parse_tri_state("false"), do: false
defp parse_tri_state("all"), do: nil
defp parse_tri_state(_), do: nil
# Get display label for button
defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do
# If payment filter is active, show payment filter label
if cycle_status_filter do
payment_filter_label(cycle_status_filter)
else
# Otherwise show boolean filter labels
boolean_filter_label(boolean_custom_fields, boolean_filters)
end
end
# Get payment filter label
defp payment_filter_label(nil), do: gettext("All")
defp payment_filter_label(:paid), do: gettext("Paid")
defp payment_filter_label(:unpaid), do: gettext("Unpaid")
# Get boolean filter label (comma-separated list of active filter names)
defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
when map_size(boolean_filters) == 0 do
gettext("All")
end
defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
# Get names of active boolean filters
active_filter_names =
boolean_filters
|> Enum.map(fn {custom_field_id_str, _value} ->
Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end)
end)
|> Enum.filter(&(&1 != nil))
|> Enum.map(& &1.name)
# Join with comma and truncate if too long
label = Enum.join(active_filter_names, ", ")
truncate_label(label, 30)
end
# Truncate label if longer than max_length
defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label
defp truncate_label(label, max_length) do
String.slice(label, 0, max_length) <> "..."
end
# Count active boolean filters
defp active_boolean_filters_count(boolean_filters) do
map_size(boolean_filters)
end
# Get CSS classes for payment filter label based on current state
defp payment_filter_label_class(current_filter, expected_value) do
base_classes = "join-item btn btn-sm"
is_active = current_filter == expected_value
cond do
# All button (nil expected)
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
# Paid button
expected_value == :paid ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
# Unpaid button
expected_value == :unpaid ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
# Get CSS classes for boolean filter label based on current state
defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
base_classes = "join-item btn btn-sm"
current_value = Map.get(boolean_filters, to_string(custom_field_id))
is_active = current_value == expected_value
cond do
# All button (nil expected)
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
# True button
expected_value == true ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
# False button
expected_value == false ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
end

View file

@ -1,147 +0,0 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
## Props
- `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn gap-2",
@cycle_status_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@cycle_status_filter)}</span>
<span :if={@cycle_status_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == nil)}
class={@cycle_status_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :paid)}
class={@cycle_status_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :unpaid)}
class={@cycle_status_filter == :unpaid && "active"}
phx-click="select_filter"
phx-value-filter="unpaid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Unpaid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("unpaid"), do: :unpaid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:unpaid), do: gettext("Unpaid")
end

View file

@ -28,6 +28,7 @@ defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
require Ash.Query
require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
@ -41,6 +42,15 @@ defmodule MvWeb.MemberLive.Index do
# 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
@ -72,6 +82,12 @@ defmodule MvWeb.MemberLive.Index do
|> 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
@ -101,10 +117,12 @@ defmodule MvWeb.MemberLive.Index do
|> 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(
@ -218,7 +236,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
new_show_current
new_show_current,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -332,7 +351,8 @@ defmodule MvWeb.MemberLive.Index do
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
# Set the new path with params
@ -361,7 +381,77 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns.show_current_cycle
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}"
@ -448,6 +538,9 @@ defmodule MvWeb.MemberLive.Index do
"""
@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)
@ -471,23 +564,68 @@ defmodule MvWeb.MemberLive.Index do
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))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
# 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:
@ -586,7 +724,8 @@ defmodule MvWeb.MemberLive.Index do
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -616,7 +755,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
@ -634,12 +774,14 @@ defmodule MvWeb.MemberLive.Index do
# 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
show_current_cycle,
boolean_filters
) do
field_str =
if is_atom(sort_field) do
@ -670,11 +812,19 @@ defmodule MvWeb.MemberLive.Index do
end
# Add show_current_cycle if true
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end
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.
@ -704,9 +854,32 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields (based on user selection)
# 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] || []
query = load_custom_field_values(query, 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)
@ -726,7 +899,9 @@ defmodule MvWeb.MemberLive.Index do
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
members = Ash.read!(query, actor: actor)
{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
@ -739,6 +914,14 @@ defmodule MvWeb.MemberLive.Index do
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
@ -1133,6 +1316,142 @@ defmodule MvWeb.MemberLive.Index do
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)
@ -1166,7 +1485,166 @@ defmodule MvWeb.MemberLive.Index do
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
(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)
_ ->
@ -1221,8 +1699,11 @@ defmodule MvWeb.MemberLive.Index do
#
# 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
members = socket.assigns.members
# 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 =

View file

@ -37,9 +37,11 @@
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
cycle_status_filter={@cycle_status_filter}
boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
<button