- 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
516 lines
17 KiB
Elixir
516 lines
17 KiB
Elixir
defmodule Mv.Membership.Member do
|
|
@moduledoc """
|
|
Ash resource representing a club member.
|
|
|
|
## Overview
|
|
Members are the core entity in the membership management system. Each member
|
|
can have:
|
|
- Personal information (name, email, phone, address)
|
|
- Optional link to a User account (1:1 relationship)
|
|
- Dynamic custom field values via CustomField system
|
|
- Full-text searchable profile
|
|
|
|
## Email Synchronization
|
|
When a member is linked to a user account, emails are automatically synchronized
|
|
bidirectionally. User.email is the source of truth on initial link.
|
|
See `Mv.EmailSync` for details.
|
|
|
|
## Relationships
|
|
- `has_many :custom_field_values` - Dynamic custom fields
|
|
- `has_one :user` - Optional authentication account link
|
|
|
|
## Validations
|
|
- Required: first_name, last_name, email
|
|
- Email format validation (using EctoCommons.EmailValidator)
|
|
- Phone number format: international format with 6-20 digits
|
|
- Postal code format: exactly 5 digits (German format)
|
|
- Date validations: birth_date and join_date not in future, exit_date after join_date
|
|
- Email uniqueness: prevents conflicts with unlinked users
|
|
|
|
## Full-Text Search
|
|
Members have a `search_vector` attribute (tsvector) that is automatically
|
|
updated via database trigger. Search includes name, email, notes, and contact fields.
|
|
"""
|
|
use Ash.Resource,
|
|
domain: Mv.Membership,
|
|
data_layer: AshPostgres.DataLayer
|
|
|
|
require Ash.Query
|
|
import Ash.Expr
|
|
|
|
# Module constants
|
|
@member_search_limit 10
|
|
@default_similarity_threshold 0.2
|
|
|
|
postgres do
|
|
table "members"
|
|
repo Mv.Repo
|
|
end
|
|
|
|
actions do
|
|
defaults [:read, :destroy]
|
|
|
|
create :create_member do
|
|
primary? true
|
|
# Custom field values can be created along with member
|
|
argument :custom_field_values, {:array, :map}
|
|
# Allow user to be passed as argument for relationship management
|
|
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
|
argument :user, :map, allow_nil?: true
|
|
|
|
accept [
|
|
:first_name,
|
|
:last_name,
|
|
:email,
|
|
:birth_date,
|
|
:paid,
|
|
:phone_number,
|
|
:join_date,
|
|
:exit_date,
|
|
:notes,
|
|
:city,
|
|
:street,
|
|
:house_number,
|
|
:postal_code
|
|
]
|
|
|
|
change manage_relationship(:custom_field_values, type: :create)
|
|
|
|
# Manage the user relationship during member creation
|
|
change manage_relationship(:user, :user,
|
|
# Look up existing user and relate to it
|
|
on_lookup: :relate,
|
|
# Error if user doesn't exist in database
|
|
on_no_match: :error,
|
|
# Error if user is already linked to another member (prevents "stealing")
|
|
on_match: :error,
|
|
# If no user provided, that's fine (optional relationship)
|
|
on_missing: :ignore
|
|
)
|
|
|
|
# Sync user email to member when linking (User → Member)
|
|
# Only runs when user relationship is being changed
|
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|
where [changing(:user)]
|
|
end
|
|
end
|
|
|
|
update :update_member do
|
|
primary? true
|
|
# Required because custom validation function cannot be done atomically
|
|
require_atomic? false
|
|
# Custom field values can be updated or created along with member
|
|
argument :custom_field_values, {:array, :map}
|
|
# Allow user to be passed as argument for relationship management
|
|
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
|
argument :user, :map, allow_nil?: true
|
|
|
|
accept [
|
|
:first_name,
|
|
:last_name,
|
|
:email,
|
|
:birth_date,
|
|
:paid,
|
|
:phone_number,
|
|
:join_date,
|
|
:exit_date,
|
|
:notes,
|
|
:city,
|
|
:street,
|
|
:house_number,
|
|
:postal_code
|
|
]
|
|
|
|
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
|
|
|
# Manage the user relationship during member update
|
|
change manage_relationship(:user, :user,
|
|
# Look up existing user and relate to it
|
|
on_lookup: :relate,
|
|
# Error if user doesn't exist in database
|
|
on_no_match: :error,
|
|
# Error if user is already linked to another member (prevents "stealing")
|
|
on_match: :error,
|
|
# If no user provided, remove existing relationship (allows user removal)
|
|
on_missing: :unrelate
|
|
)
|
|
|
|
# Sync member email to user when email changes (Member → User)
|
|
# Only runs when email is being changed
|
|
change Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
|
where [changing(:email)]
|
|
end
|
|
|
|
# Sync user email to member when linking (User → Member)
|
|
# Only runs when user relationship is being changed
|
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|
where [changing(:user)]
|
|
end
|
|
end
|
|
|
|
# Action to handle fuzzy search on specific fields
|
|
read :search do
|
|
argument :query, :string, allow_nil?: true
|
|
argument :similarity_threshold, :float, allow_nil?: true
|
|
|
|
prepare fn query, _ctx ->
|
|
q = Ash.Query.get_argument(query, :query) || ""
|
|
|
|
# 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)
|
|
pat = "%" <> q2 <> "%"
|
|
|
|
# FTS as main filter and fuzzy search just for first name, last name and strees
|
|
query
|
|
|> Ash.Query.filter(
|
|
expr(
|
|
# Substring on numeric-like fields (best effort, supports middle substrings)
|
|
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
|
|
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
|
|
contains(postal_code, ^q2) or
|
|
contains(house_number, ^q2) or
|
|
contains(phone_number, ^q2) or
|
|
contains(email, ^q2) or
|
|
contains(city, ^q2) or ilike(city, ^pat) or
|
|
fragment("? % first_name", ^q2) or
|
|
fragment("? % last_name", ^q2) or
|
|
fragment("? % street", ^q2) or
|
|
fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
|
|
fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
|
|
fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
|
|
fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
|
|
fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
|
|
fragment("similarity(street, ?) > ?", ^q2, ^threshold)
|
|
)
|
|
)
|
|
else
|
|
query
|
|
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) > ?",
|
|
^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(@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(@member_search_limit)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
@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
|
|
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
|
|
|
|
@spec filter_by_email_match(any(), any()) :: any()
|
|
def filter_by_email_match(members, _user_email), do: members
|
|
|
|
validations do
|
|
# Required fields are covered by allow_nil? false
|
|
|
|
# First name and last name must not be empty
|
|
validate present(:first_name)
|
|
validate present(:last_name)
|
|
validate present(:email)
|
|
|
|
# Email uniqueness check for all actions that change the email attribute
|
|
# Validates that member email is not already used by another (unlinked) user
|
|
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
|
|
|
|
# Prevent linking to a user that already has a member
|
|
# This validation prevents "stealing" users from other members by checking
|
|
# if the target user is already linked to a different member
|
|
# This is necessary because manage_relationship's on_match: :error only checks
|
|
# if the user is already linked to THIS specific member, not ANY member
|
|
validate fn changeset, _context ->
|
|
user_arg = Ash.Changeset.get_argument(changeset, :user)
|
|
|
|
if user_arg && user_arg[:id] do
|
|
user_id = user_arg[:id]
|
|
current_member_id = changeset.data.id
|
|
|
|
# Check the current state of the user in the database
|
|
case Ash.get(Mv.Accounts.User, user_id) do
|
|
# User is free to be linked
|
|
{:ok, %{member_id: nil}} ->
|
|
:ok
|
|
|
|
# User already linked to this member (update scenario)
|
|
{:ok, %{member_id: ^current_member_id}} ->
|
|
:ok
|
|
|
|
{:ok, %{member_id: _other_member_id}} ->
|
|
# User is linked to a different member - prevent "stealing"
|
|
{:error, field: :user, message: "User is already linked to another member"}
|
|
|
|
{:error, _} ->
|
|
{:error, field: :user, message: "User not found"}
|
|
end
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Birth date not in the future
|
|
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
|
|
where: [present(:birth_date)],
|
|
message: "cannot be in the future"
|
|
|
|
# Join date not in the future
|
|
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
|
|
where: [present(:join_date)],
|
|
message: "cannot be in the future"
|
|
|
|
# Exit date not before join date
|
|
validate compare(:exit_date, greater_than: :join_date),
|
|
where: [present([:join_date, :exit_date])],
|
|
message: "cannot be before join date"
|
|
|
|
# Phone number format (only if set)
|
|
validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/),
|
|
where: [present(:phone_number)],
|
|
message: "is not a valid phone number"
|
|
|
|
# Postal code format (only if set)
|
|
validate match(:postal_code, ~r/^\d{5}$/),
|
|
where: [present(:postal_code)],
|
|
message: "must consist of 5 digits"
|
|
|
|
# Email validation with EctoCommons.EmailValidator
|
|
validate fn changeset, _ ->
|
|
email = Ash.Changeset.get_attribute(changeset, :email)
|
|
|
|
changeset2 =
|
|
{%{}, %{email: :string}}
|
|
|> Ecto.Changeset.cast(%{email: email}, [:email])
|
|
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
|
|
|
|
if changeset2.valid? do
|
|
:ok
|
|
else
|
|
{:error, field: :email, message: "is not a valid email"}
|
|
end
|
|
end
|
|
end
|
|
|
|
attributes do
|
|
uuid_v7_primary_key :id
|
|
|
|
attribute :first_name, :string do
|
|
allow_nil? false
|
|
constraints min_length: 1
|
|
end
|
|
|
|
attribute :last_name, :string do
|
|
allow_nil? false
|
|
constraints min_length: 1
|
|
end
|
|
|
|
# IMPORTANT: Email Synchronization
|
|
# When member and user are linked, emails are automatically synced bidirectionally.
|
|
# User.email is the source of truth - when a link is established, member.email
|
|
# is overridden to match user.email. Subsequent changes to either email will
|
|
# sync to the other resource.
|
|
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
|
|
# Mv.EmailSync.Changes.SyncMemberEmailToUser
|
|
attribute :email, :string do
|
|
allow_nil? false
|
|
constraints min_length: 5, max_length: 254
|
|
end
|
|
|
|
attribute :birth_date, :date do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :paid, :boolean do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :phone_number, :string do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :join_date, :date do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :exit_date, :date do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :notes, :string do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :city, :string do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :street, :string do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :house_number, :string do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :postal_code, :string do
|
|
allow_nil? true
|
|
end
|
|
|
|
attribute :search_vector, AshPostgres.Tsvector,
|
|
writable?: false,
|
|
public?: false,
|
|
select_by_default?: false
|
|
end
|
|
|
|
relationships do
|
|
has_many :custom_field_values, Mv.Membership.CustomFieldValue
|
|
# 1:1 relationship - Member can optionally have one User
|
|
# This references the User's member_id attribute
|
|
# The relationship is optional (allow_nil? true by default)
|
|
has_one :user, Mv.Accounts.User
|
|
end
|
|
|
|
# Define identities for upsert operations
|
|
identities do
|
|
identity :unique_email, [:email]
|
|
end
|
|
|
|
@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()
|
|
|
|
if String.trim(q) == "" do
|
|
query
|
|
else
|
|
args =
|
|
case opts[:fields] || opts["fields"] do
|
|
nil -> %{query: q}
|
|
fields -> %{query: q, fields: fields}
|
|
end
|
|
|
|
Ash.Query.for_read(query, :search, args)
|
|
end
|
|
end
|
|
end
|