diff --git a/config/test.exs b/config/test.exs index b47c764..b48c408 100644 --- a/config/test.exs +++ b/config/test.exs @@ -12,10 +12,7 @@ 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() * 8, - queue_target: 5000, - queue_interval: 1000, - timeout: 30_000 + pool_size: System.schedulers_online() * 4 # 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 4ef355d..73bfcd9 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -19,12 +19,6 @@ 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 @@ -39,42 +33,6 @@ 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 deleted file mode 100644 index 9286ace..0000000 --- a/lib/mv_web/live/components/member_filter_component.ex +++ /dev/null @@ -1,444 +0,0 @@ -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 new file mode 100644 index 0000000..9caaa1f --- /dev/null +++ b/lib/mv_web/live/components/payment_filter_component.ex @@ -0,0 +1,147 @@ +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 50b0cfa..2cf7392 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -28,7 +28,6 @@ defmodule MvWeb.MemberLive.Index do use MvWeb, :live_view require Ash.Query - require Logger import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] @@ -42,15 +41,6 @@ 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 @@ -82,12 +72,6 @@ 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 @@ -117,12 +101,10 @@ 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( @@ -236,8 +218,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - new_show_current, - socket.assigns.boolean_custom_field_filters + new_show_current ) new_path = ~p"/members?#{query_params}" @@ -351,8 +332,7 @@ defmodule MvWeb.MemberLive.Index do existing_field_query, existing_sort_query, socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.show_current_cycle ) # Set the new path with params @@ -381,77 +361,7 @@ defmodule MvWeb.MemberLive.Index do 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 + socket.assigns.show_current_cycle ) new_path = ~p"/members?#{query_params}" @@ -538,9 +448,6 @@ 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) @@ -564,68 +471,23 @@ 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)) - - # 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 + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() {: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: @@ -724,8 +586,7 @@ defmodule MvWeb.MemberLive.Index do field_str, Atom.to_string(order), socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + socket.assigns.show_current_cycle ) new_path = ~p"/members?#{query_params}" @@ -755,8 +616,7 @@ 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.boolean_custom_field_filters + socket.assigns.show_current_cycle ) |> maybe_add_field_selection(socket.assigns[:user_field_selection]) @@ -774,14 +634,12 @@ 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, - boolean_filters + show_current_cycle ) do field_str = if is_atom(sort_field) do @@ -812,19 +670,11 @@ defmodule MvWeb.MemberLive.Index do 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) + if show_current_cycle do + Map.put(base_params, "show_current_cycle", "true") + else + base_params + end end # Loads members from the database with custom field values and applies search/sort/payment filters. @@ -854,32 +704,9 @@ defmodule MvWeb.MemberLive.Index do |> 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 + # Load custom field values for visible custom fields (based on user selection) 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) + query = load_custom_field_values(query, visible_custom_field_ids) # Load membership fee cycles for status display query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) @@ -899,9 +726,7 @@ defmodule MvWeb.MemberLive.Index do # 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") + members = Ash.read!(query, actor: actor) # Custom field values are already filtered at the database level in load_custom_field_values/2 # No need for in-memory filtering anymore @@ -914,14 +739,6 @@ 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 @@ -1316,142 +1133,6 @@ 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) @@ -1485,166 +1166,7 @@ defmodule MvWeb.MemberLive.Index do 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) + (cfv.custom_field && cfv.custom_field.id == custom_field.id) end) _ -> @@ -1699,11 +1221,8 @@ 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 - # Handle case where members haven't been loaded yet (e.g., when signature didn't change) - members = socket.assigns[:members] || [] + 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 394db2c..b2af205 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -37,11 +37,9 @@ placeholder={gettext("Search...")} /> <.live_component - module={MvWeb.Components.MemberFilterComponent} - id="member-filter" + module={MvWeb.Components.PaymentFilterComponent} + id="payment-filter" cycle_status_filter={@cycle_status_filter} - boolean_custom_fields={@boolean_custom_fields} - boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} />