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 require Ash.Sort @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_boolean_custom_field_filters( opts[:boolean_custom_field_filters], opts[:boolean_custom_fields] ) |> apply_date_filters(opts[:date_filters]) |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) |> apply_cycle_status_filter( opts[:cycle_status_filter], opts[:show_current_cycle], today(opts) ) |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) 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 # --------------------------------------------------------------------------- # Boolean custom-field filters (AND across selected fields) # # Mirrors the previous in-memory behaviour: a member matches a `{field => bool}` # filter only if it has a stored value row for that field whose boolean value # equals `bool`. A member with no value row is excluded. Uses a JSONB # containment predicate (`value @> '{"value": }'`), which the GIN index on # `custom_field_values.value` can serve. # --------------------------------------------------------------------------- defp apply_boolean_custom_field_filters(query, filters, _boolean_fields) when filters in [nil, %{}], do: query defp apply_boolean_custom_field_filters(query, filters, boolean_fields) do valid_ids = valid_id_set(boolean_fields) filters |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) |> Enum.reduce(query, fn {id_str, bool}, q -> case Ecto.UUID.cast(id_str) do {:ok, uuid} -> apply_one_boolean_filter(q, uuid, bool) _ -> q end end) end defp apply_one_boolean_filter(query, uuid, bool) when is_boolean(bool) do Ash.Query.filter( query, expr( exists( custom_field_values, custom_field_id == ^uuid and fragment("? @> jsonb_build_object('value', ?::boolean)", value, ^bool) ) ) ) end defp apply_one_boolean_filter(query, _uuid, _bool), do: query # --------------------------------------------------------------------------- # 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) defp apply_custom_date_filters(query, filters, custom_fields) when is_map(filters) and is_list(custom_fields), do: DateFilter.apply_custom_date_ash_filter(query, filters, custom_fields) defp apply_custom_date_filters(query, _filters, _custom_fields), do: query # --------------------------------------------------------------------------- # Sort (always append the unique id tie-breaker for keyset stability) # --------------------------------------------------------------------------- defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) # Composite "Member" column sub-fields (§1.21). Sorting by one name part uses # the other name part (then id) as a stable tie-breaker; the email that shares # the cell is display-only and never a sort key. Shared by the composite # sub-field menu and the separate first/last-name columns. defp apply_sort(query, field, order, _custom_fields) when field in [:last_name, "last_name"] do Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}]) end defp apply_sort(query, field, order, _custom_fields) when field in [:first_name, "first_name"] do Ash.Query.sort(query, [{:first_name, order}, {:last_name, order}, {:id, :asc}]) end # Composite "Address" column: sort by city, then postal code, then street. defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do Ash.Query.sort(query, [ {:city, order}, {:postal_code, order}, {:street, order}, {:id, :asc} ]) end defp apply_sort(query, field, order, custom_fields) do case sort_key(field) do nil -> apply_custom_field_sort(query, field, order, custom_fields) key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) end end # Sorts by a custom field's JSONB value DB-side. The value is read from the # first matching `custom_field_values` row for the field, cast per the field's # value type so integers sort numerically and dates chronologically. Empty # strings and members without a value row sort last in both directions # (NULLS LAST), mirroring the previous in-memory `CustomFieldSort` behaviour. defp apply_custom_field_sort(query, field, order, custom_fields) do with id_str when is_binary(id_str) <- custom_field_id(field), %{} = cf <- Enum.find(custom_fields, &(to_string(&1.id) == id_str)) do nils_order = if order == :desc, do: :desc_nils_last, else: :asc_nils_last Ash.Query.sort(query, [ {custom_field_sort_calc(cf.value_type, cf.id), nils_order}, {:id, :asc} ]) else _ -> Ash.Query.sort(query, id: :asc) end end defp custom_field_id(field) when is_atom(field), do: custom_field_id(Atom.to_string(field)) defp custom_field_id(field) when is_binary(field) do case String.split(field, Mv.Constants.custom_field_prefix()) do ["", id_str] -> id_str _ -> nil end end defp custom_field_id(_), do: nil # Maps the field's value type to the first matching custom-field value's typed # sort key (scoped to the field), backed by the `sort_*` calculations on # `CustomFieldValue` (a raw fragment is not allowed as an aggregate `field:`). defp custom_field_sort_calc(:integer, id), do: Ash.Sort.expr_sort( first(custom_field_values, field: :sort_numeric, query: [filter: expr(custom_field_id == ^id)] ), :decimal ) defp custom_field_sort_calc(:date, id), do: Ash.Sort.expr_sort( first(custom_field_values, field: :sort_date, query: [filter: expr(custom_field_id == ^id)] ), :date ) defp custom_field_sort_calc(:boolean, id), do: Ash.Sort.expr_sort( first(custom_field_values, field: :sort_boolean, query: [filter: expr(custom_field_id == ^id)] ), :boolean ) defp custom_field_sort_calc(_type, id), do: Ash.Sort.expr_sort( first(custom_field_values, field: :sort_text, query: [filter: expr(custom_field_id == ^id)] ), :string ) # 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