From ce21ebad9bf48c71454b0059506a57fb11dd390f Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:52:12 +0100 Subject: [PATCH] feat: add member fuzzy search for linking (#168) --- lib/membership/member.ex | 77 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index eeb12c9..8464388 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -152,7 +152,8 @@ defmodule Mv.Membership.Member do prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results + # 0.2 as similarity threshold (recommended) + # Lower value can lead to more results but also to more unspecific results threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 if is_binary(q) and String.trim(q) != "" do @@ -187,8 +188,82 @@ defmodule Mv.Membership.Member do end end end + + # Action to find members available for linking to a user account + # Returns only unlinked members (user_id == nil), limited to 10 results + # + # Special behavior for email matching: + # - When user_email AND search_query are both provided: filter by email (email takes precedence) + # - When only user_email provided: return all unlinked members (caller should use filter_by_email_match helper) + # - When only search_query provided: filter by search terms + read :available_for_linking do + argument :user_email, :string, allow_nil?: true + argument :search_query, :string, allow_nil?: true + + prepare fn query, _ctx -> + user_email = Ash.Query.get_argument(query, :user_email) + search_query = Ash.Query.get_argument(query, :search_query) + + # Start with base filter: only unlinked members + base_query = Ash.Query.filter(query, is_nil(user)) + + # Determine filtering strategy + # Priority: search_query (if present) > no filters + # user_email is used for POST-filtering via filter_by_email_match helper + if not is_nil(search_query) and String.trim(search_query) != "" do + # Search query present: Use fuzzy search (regardless of user_email) + trimmed = String.trim(search_query) + + # Use same fuzzy search as :search action (PostgreSQL Trigram + FTS) + base_query + |> Ash.Query.filter( + expr( + # Full-text search + # Trigram similarity for names + # Exact substring match for email + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed) or + fragment("? % first_name", ^trimmed) or + fragment("? % last_name", ^trimmed) or + fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or + fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or + fragment("similarity(first_name, ?) > 0.2", ^trimmed) or + fragment("similarity(last_name, ?) > 0.2", ^trimmed) or + contains(email, ^trimmed) + ) + ) + |> Ash.Query.limit(10) + else + # No search query: return all unlinked members + # Caller should use filter_by_email_match helper for email match logic + base_query + |> Ash.Query.limit(10) + end + end + end end + # Public helper function to apply email match logic after query execution + # This should be called after using :available_for_linking with user_email argument + # + # If a member with matching email exists, returns only that member + # Otherwise returns all members (no filtering) + def filter_by_email_match(members, user_email) + when is_list(members) and is_binary(user_email) do + # Check if any member matches the email + email_match = Enum.find(members, &(&1.email == user_email)) + + if email_match do + # Return only the email-matched member + [email_match] + else + # No email match, return all members + members + end + end + + def filter_by_email_match(members, _user_email), do: members + validations do # Required fields are covered by allow_nil? false