mitgliederverwaltung/lib/membership/member.ex
carla 827b2acd6a
All checks were successful
continuous-integration/drone/push Build is passing
formatting and refactor member fields constant
2025-12-02 12:20:37 +01:00

574 lines
20 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
# Use constants from Mv.Constants for member fields
# This ensures consistency across the codebase
@member_fields Mv.Constants.member_fields()
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 @member_fields
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 @member_fields
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
#
# Filtering behavior:
# - If search_query provided: fuzzy search on names and email
# - If no search_query: return all unlinked members (up to limit)
# - user_email should be handled by caller with filter_by_email_match/2
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)
query
|> Ash.Query.filter(is_nil(user))
|> apply_linking_filters(user_email, search_query)
|> Ash.Query.limit(@member_search_limit)
end
end
end
@doc """
Filters members list based on email match priority.
Priority logic:
1. If email matches a member: return ONLY that member (highest priority)
2. If email doesn't match: return all members (for display in dropdown)
This is used with :available_for_linking action to implement email-priority behavior:
- user_email matches → Only this member
- user_email does NOT match + NO search_query → All unlinked members
- user_email does NOT match + search_query provided → search_query filtered members
## Parameters
- `members` - List of Member structs (from :available_for_linking action)
- `user_email` - Email string to match against member emails
## Returns
- List of Member structs (either single email 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
email_match = Enum.find(members, &(&1.email == user_email))
if email_match do
# Email match found - return only this member (highest priority)
[email_match]
else
# No email match - return all members unchanged
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 """
Checks if a member field should be shown in the overview.
Reads the visibility configuration from Settings resource. If a field is not
configured in settings, it defaults to `true` (visible).
## Parameters
- `field` - Atom representing the member field name (e.g., `:email`, `:street`)
## Returns
- `true` if the field should be shown in overview (default)
- `false` if the field is configured as hidden in settings
## Examples
iex> Member.show_in_overview?(:email)
true
iex> Member.show_in_overview?(:street)
true # or false if configured in settings
"""
@spec show_in_overview?(atom()) :: boolean()
def show_in_overview?(field) when is_atom(field) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
visibility_config = settings.member_field_visibility || %{}
# Normalize map keys to atoms (JSONB may return string keys)
normalized_config = normalize_visibility_config(visibility_config)
# Get value from normalized config, default to true
Map.get(normalized_config, field, true)
{:error, _} ->
# If settings can't be loaded, default to visible
true
end
end
def show_in_overview?(_), do: true
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
@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
# Private helper to apply filters for :available_for_linking action
# user_email: may be nil/empty when creating new user, or populated when editing
# search_query: optional search term for fuzzy matching
#
# Logic: (email == user_email) OR (fuzzy_search on search_query)
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches
# - This allows a single filter expression instead of duplicating fuzzy search logic
#
# Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires
# multiple OR conditions for good search quality (FTS + trigram similarity + substring)
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp apply_linking_filters(query, user_email, search_query) do
has_search = search_query && String.trim(search_query) != ""
# Use empty string instead of nil to simplify filter logic
trimmed_email = if user_email, do: String.trim(user_email), else: ""
if has_search do
# Search query provided: return email-match OR fuzzy-search candidates
trimmed_search = String.trim(search_query)
query
|> Ash.Query.filter(
expr(
# Email match candidate (for filter_by_email_match priority)
# If email is "", this is always false and fuzzy search takes over
# Fuzzy search candidates
email == ^trimmed_email or
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or
fragment("? % first_name", ^trimmed_search) or
fragment("? % last_name", ^trimmed_search) or
fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or
fragment(
"word_similarity(?, last_name) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(first_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(last_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
contains(email, ^trimmed_search)
)
)
else
# No search query: return all unlinked (filter_by_email_match will prioritize email if provided)
query
end
end
end