diff --git a/config/test.exs b/config/test.exs index b48c408..b47c764 100644 --- a/config/test.exs +++ b/config/test.exs @@ -12,7 +12,10 @@ config :mv, Mv.Repo, port: System.get_env("TEST_POSTGRES_PORT", "5000"), database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, - pool_size: System.schedulers_online() * 4 + pool_size: System.schedulers_online() * 8, + queue_target: 5000, + queue_interval: 1000, + timeout: 30_000 # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 73bfcd9..4ef355d 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -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. diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex new file mode 100644 index 0000000..9286ace --- /dev/null +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -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 `
` and `` 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""" +
+ + + + +
+ """ + 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 diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex deleted file mode 100644 index 9caaa1f..0000000 --- a/lib/mv_web/live/components/payment_filter_component.ex +++ /dev/null @@ -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""" -
- - - -
- """ - 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 2cf7392..50b0cfa 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -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_") @custom_field_prefix Mv.Constants.custom_field_prefix() + # Prefix used for boolean custom field filter URL parameters (e.g., "bf_") + @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_=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 = diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index b2af205..394db2c 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -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)} />