feat(member): back the overview with a keyset-paginated :overview read action

This commit is contained in:
Simon 2026-07-03 11:12:38 +02:00
parent b745b13ca5
commit e64f55c36a
6 changed files with 615 additions and 21 deletions

View file

@ -266,6 +266,15 @@ defmodule Mv.Membership.Member do
accept [:vereinfacht_contact_id]
end
# Keyset-paginated read for the member overview. Filters and sort are
# composed onto the query by `MvWeb.MemberLive.Index.OverviewQuery`; this
# action only enables keyset pagination with a unique `id` tie-breaker so
# pages never skip or duplicate rows.
read :overview do
description "Keyset-paginated member overview read"
pagination keyset?: true, default_limit: 50, required?: false
end
# Action to handle fuzzy search on specific fields
read :search do
argument :query, :string, allow_nil?: true
@ -279,27 +288,7 @@ defmodule Mv.Membership.Member do
threshold =
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
q2_sanitized = sanitize_search_query(q2)
pat = "%" <> q2_sanitized <> "%"
# Build search filters grouped by search type for maintainability
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
fts_match = build_fts_filter(q2)
substring_match = build_substring_filter(q2_sanitized, pat)
custom_field_match = build_custom_field_filter(pat)
fuzzy_match = build_fuzzy_filter(q2, threshold)
query
|> Ash.Query.filter(
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
)
else
query
end
apply_fuzzy_search_filter(query, q, threshold)
end
end
@ -738,6 +727,13 @@ defmodule Mv.Membership.Member do
end
end
aggregates do
# Alphabetically-first group name, used to sort the overview by group DB-side.
# Mirrors the previous in-memory "first group name" sort key; members with no
# groups yield nil (sorted last ascending via NULLS LAST).
min :first_group_name, :groups, :name
end
# Define identities for upsert operations
identities do
identity :unique_email, [:email]
@ -1212,6 +1208,43 @@ defmodule Mv.Membership.Member do
end
end
@doc """
Applies the same fuzzy/full-text search filter as the `:search` action as a
plain filter on the given query, without switching the query's read action.
Used by the overview query builder so search composes with the `:overview`
keyset-paginated read action instead of replacing it.
"""
@spec apply_overview_search(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
def apply_overview_search(query, q) do
apply_fuzzy_search_filter(query, q || "", @default_similarity_threshold)
end
# Shared by the `:search` action and `apply_overview_search/2`.
defp apply_fuzzy_search_filter(query, q, threshold) do
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
q2_sanitized = sanitize_search_query(q2)
pat = "%" <> q2_sanitized <> "%"
# Build search filters grouped by search type for maintainability
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
fts_match = build_fts_filter(q2)
substring_match = build_substring_filter(q2_sanitized, pat)
custom_field_match = build_custom_field_filter(pat)
fuzzy_match = build_fuzzy_filter(q2, threshold)
Ash.Query.filter(
query,
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
)
else
query
end
end
# ============================================================================
# Search Input Sanitization
# ============================================================================

View file

@ -45,6 +45,7 @@ defmodule Mv.Membership do
resource Mv.Membership.Member do
define :create_member, action: :create_member
define :list_members, action: :read
define :overview_members, action: :overview
define :update_member, action: :update_member
define :destroy_member, action: :destroy
end

View file

@ -0,0 +1,261 @@
defmodule MvWeb.MemberLive.Index.OverviewQuery do
@moduledoc """
Builds the Ash query for the member overview from the LiveView's filter/sort
state, so every filter and sort resolves in PostgreSQL via the `:overview`
keyset-paginated read action.
All previously in-memory passes (cycle status, boolean/date custom fields,
group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker
is always appended to the sort so keyset pages never skip or duplicate rows.
`build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:`
options to `Ash.read/2`.
"""
import Ash.Expr
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.DateFilter
require Ash.Query
@type opts :: %{optional(atom()) => term()}
@doc """
Builds the `:overview` query from the given filter/sort options.
Recognised keys (all optional):
* `:search` full-text search string
* `:group_filters` / `:groups` `%{group_id => :in | :not_in}` and the valid groups
* `:fee_type_filters` / `:fee_types` `%{fee_type_id => :in | :not_in}` and valid fee types
* `:date_filters` built-in join/exit date filter map (see `DateFilter`)
* `:sort_field` / `:sort_order` sort key and direction
* `:today` reference date for cycle math (defaults to `Date.utc_today/0`)
"""
@spec build(opts()) :: Ash.Query.t()
def build(opts \\ %{}) do
Member
|> Ash.Query.for_read(:overview)
|> apply_search(opts[:search])
|> apply_group_filters(opts[:group_filters], opts[:groups])
|> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types])
|> apply_date_filters(opts[:date_filters])
|> apply_cycle_status_filter(
opts[:cycle_status_filter],
opts[:show_current_cycle],
today(opts)
)
|> apply_sort(opts[:sort_field], opts[:sort_order])
end
defp today(opts), do: opts[:today] || Date.utc_today()
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
defp apply_search(query, search) when is_binary(search),
do: Member.apply_overview_search(query, search)
defp apply_search(query, _), do: query
# ---------------------------------------------------------------------------
# Group filters (AND across selected groups)
# ---------------------------------------------------------------------------
defp apply_group_filters(query, group_filters, _groups)
when group_filters in [nil, %{}],
do: query
defp apply_group_filters(query, group_filters, groups) do
valid_ids = valid_id_set(groups)
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
if MapSet.member?(valid_ids, group_id_str) do
apply_one_group_filter(q, group_id_str, value)
else
q
end
end)
end
defp apply_one_group_filter(query, group_id_str, :in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid)))
_ -> query
end
end
defp apply_one_group_filter(query, group_id_str, :not_in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid)))
_ -> query
end
end
defp apply_one_group_filter(query, _id, _value), do: query
# ---------------------------------------------------------------------------
# Fee-type filters (:in OR; :not_in AND)
# ---------------------------------------------------------------------------
defp apply_fee_type_filters(query, fee_type_filters, _fee_types)
when fee_type_filters in [nil, %{}],
do: query
defp apply_fee_type_filters(query, fee_type_filters, fee_types) do
valid_ids = valid_id_set(fee_types)
{in_filters, not_in_filters} =
fee_type_filters
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|> Enum.split_with(fn {_, value} -> value == :in end)
in_uuids = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str end))
query =
if in_uuids == [] do
query
else
Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids))
end
Enum.reduce(not_in_filters, query, fn {id_str, _}, q ->
case Ecto.UUID.cast(id_str) do
{:ok, uuid} ->
Ash.Query.filter(
q,
expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id))
)
_ ->
q
end
end)
end
# ---------------------------------------------------------------------------
# Cycle-status filter (paid/unpaid, current or last-completed cycle)
#
# Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member
# matches only when its selected-cycle status equals the requested status;
# members with no matching cycle (nil aggregate) are excluded — exactly as the
# previous in-memory classifier behaved.
# ---------------------------------------------------------------------------
defp apply_cycle_status_filter(query, status, _show_current, _today)
when status not in [:paid, :unpaid],
do: query
defp apply_cycle_status_filter(query, status, true = _show_current, today) do
# Current cycle: contains today; when several would, the one with the latest
# cycle_start wins. Keep members whose winning current cycle has `status`.
Ash.Query.filter(
query,
expr(
exists(
membership_fee_cycles,
cycle_start <= ^today and cycle_end >= ^today and status == ^status and
not exists(
member.membership_fee_cycles,
cycle_start <= ^today and cycle_end >= ^today and
cycle_start > parent(cycle_start)
)
)
)
)
end
defp apply_cycle_status_filter(query, status, _show_current, today) do
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
Ash.Query.filter(
query,
expr(
exists(
membership_fee_cycles,
cycle_end < ^today and status == ^status and
not exists(
member.membership_fee_cycles,
cycle_end < ^today and cycle_start > parent(cycle_start)
)
)
)
)
end
# ---------------------------------------------------------------------------
# Built-in date filters (join/exit)
# ---------------------------------------------------------------------------
defp apply_date_filters(query, nil), do: query
defp apply_date_filters(query, filters) when is_map(filters),
do: DateFilter.apply_ash_filter(query, filters)
# ---------------------------------------------------------------------------
# Sort (always append the unique id tie-breaker for keyset stability)
# ---------------------------------------------------------------------------
defp apply_sort(query, nil, _order), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, field, order) do
case sort_key(field) do
nil -> Ash.Query.sort(query, id: :asc)
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
end
end
# Resolves a sort field (atom or string) to a DB sort key, or nil if it is a
# computed field handled elsewhere / not sortable.
defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"],
do: "membership_fee_type.name"
defp sort_key(field) when field in [:groups, "groups"], do: :first_group_name
defp sort_key(field) when is_atom(field) do
if field in member_sort_fields(), do: field, else: nil
end
defp sort_key(field) when is_binary(field) do
allowed = MapSet.new(member_sort_fields(), &Atom.to_string/1)
if MapSet.member?(allowed, field), do: String.to_existing_atom(field), else: nil
end
defp sort_key(_), do: nil
defp member_sort_fields do
Mv.Constants.member_fields() -- [:notes]
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp valid_id_set(records) when is_list(records) do
records
|> Enum.map(&to_string(&1.id))
|> Enum.map(&normalize_uuid/1)
|> Enum.reject(&is_nil/1)
|> MapSet.new()
end
defp valid_id_set(_), do: MapSet.new()
defp normalize_uuid(raw) when is_binary(raw) do
case Ecto.UUID.cast(String.trim(raw)) do
{:ok, uuid} -> to_string(uuid)
_ -> nil
end
end
defp normalize_uuid(_), do: nil
defp cast_uuids(id_strs) do
id_strs
|> Enum.map(&Ecto.UUID.cast/1)
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(fn {:ok, uuid} -> uuid end)
end
end