refactor: add typespecs and module constants

- Add @spec for public functions in Member and UserLive.Form
- Replace magic numbers with module constants:
  - @member_search_limit = 10
  - @default_similarity_threshold = 0.2
- Add comprehensive @doc for filter_by_email_match and fuzzy_search
This commit is contained in:
Moritz 2025-11-20 16:00:50 +01:00
parent 19a6480594
commit dd90e79daf
2 changed files with 83 additions and 17 deletions

View file

@ -38,6 +38,10 @@ defmodule Mv.Membership.Member do
require Ash.Query
import Ash.Expr
# Module constants
@member_search_limit 10
@default_similarity_threshold 0.2
postgres do
table "members"
repo Mv.Repo
@ -152,9 +156,10 @@ 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
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
# Use default similarity threshold if not provided
# Lower value leads to more results but also more unspecific results
threshold =
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
@ -226,28 +231,58 @@ defmodule Mv.Membership.Member do
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
fragment(
"word_similarity(?, last_name) > ?",
^trimmed,
^@default_similarity_threshold
) or
fragment(
"similarity(first_name, ?) > ?",
^trimmed,
^@default_similarity_threshold
) or
fragment("similarity(last_name, ?) > ?", ^trimmed, ^@default_similarity_threshold) or
contains(email, ^trimmed)
)
)
|> Ash.Query.limit(10)
|> Ash.Query.limit(@member_search_limit)
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)
|> Ash.Query.limit(@member_search_limit)
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)
@doc """
Filters members list to return only email match if exists.
If a member with matching email exists in the list, returns only that member.
Otherwise returns all members unchanged (no filtering).
This is typically used after calling `:available_for_linking` action with
a user_email argument to apply email-match priority logic.
## Parameters
- `members` - List of Member structs to filter
- `user_email` - Email string to match against member emails
## Returns
- List of Member structs (either single match or all members)
## Examples
iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
iex> filter_by_email_match(members, "test@example.com")
[%Member{email: "test@example.com"}]
iex> filter_by_email_match(members, "nomatch@example.com")
[%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
"""
@spec filter_by_email_match([t()], String.t()) :: [t()]
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
@ -262,6 +297,7 @@ defmodule Mv.Membership.Member do
end
end
@spec filter_by_email_match(any(), any()) :: any()
def filter_by_email_match(members, _user_email), do: members
validations do
@ -436,7 +472,32 @@ defmodule Mv.Membership.Member do
identity :unique_email, [:email]
end
# Fuzzy Search function that can be called by live view and calls search action
@doc """
Performs fuzzy search on members using PostgreSQL trigram similarity.
Wraps the `:search` action with convenient opts-based argument passing.
Searches across first_name, last_name, email, and other text fields using
full-text search combined with trigram similarity.
## Parameters
- `query` - Ash.Query.t() to apply search to
- `opts` - Keyword list or map with search options:
- `:query` or `"query"` - Search string
- `:fields` or `"fields"` - Optional field restrictions
## Returns
- Modified Ash.Query.t() with search filters applied
## Examples
iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!()
[%Member{first_name: "Greta", ...}]
iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant
[%Member{first_name: "Greta", ...}]
"""
@spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t()
def fuzzy_search(query, opts) do
q = (opts[:query] || opts["query"] || "") |> to_string()

View file

@ -267,6 +267,7 @@ defmodule MvWeb.UserLive.Form do
|> assign_form()}
end
@spec return_to(String.t() | nil) :: String.t()
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@ -383,8 +384,10 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
form =
if user do
@ -404,10 +407,11 @@ defmodule MvWeb.UserLive.Form do
assign(socket, form: to_form(form))
end
@spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t()
defp return_path("index", _user), do: ~p"/users"
defp return_path("show", user), do: ~p"/users/#{user.id}"
# Load initial members when the form is loaded or member is unlinked
@spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp load_initial_members(socket) do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
@ -421,7 +425,8 @@ defmodule MvWeb.UserLive.Form do
|> assign(show_member_dropdown: false)
end
# Load members based on search query
@spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) ::
Phoenix.LiveView.Socket.t()
defp load_available_members(socket, query) do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
@ -430,7 +435,7 @@ defmodule MvWeb.UserLive.Form do
assign(socket, available_members: members)
end
# Query available members using the Ash action
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
defp load_members_for_linking(user_email, search_query) do
user_email_str = if user_email, do: to_string(user_email), else: nil
search_query_str = if search_query && search_query != "", do: search_query, else: nil