feat: add member fuzzy search for linking (#168)

This commit is contained in:
Moritz 2025-11-20 15:52:12 +01:00
parent 2781f77a72
commit ce21ebad9b

View file

@ -152,7 +152,8 @@ defmodule Mv.Membership.Member do
prepare fn query, _ctx -> prepare fn query, _ctx ->
q = Ash.Query.get_argument(query, :query) || "" 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 threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
if is_binary(q) and String.trim(q) != "" do if is_binary(q) and String.trim(q) != "" do
@ -187,8 +188,82 @@ defmodule Mv.Membership.Member do
end end
end 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 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 validations do
# Required fields are covered by allow_nil? false # Required fields are covered by allow_nil? false