mitgliederverwaltung/lib/membership/member.ex
Moritz e9c53cc520
All checks were successful
continuous-integration/drone/push Build is passing
refactor: reduce nesting depth and improve code readability
- Replace Enum.map |> Enum.join with Enum.map_join for efficiency
- Extract helper functions to reduce nesting depth from 4 to 2
- Rename is_current_cycle? to current_cycle? following Elixir conventions
2025-12-15 11:50:08 +01:00

978 lines
34 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: 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, contact fields,
and all custom field values. Custom field values are automatically included in
the search vector with weight 'C' (same as phone_number, city, etc.).
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
require Ash.Query
import Ash.Expr
# Module constants
@member_search_limit 10
# Similarity threshold for fuzzy name/address matching.
# Lower value = more results but less accurate (0.1-0.9)
#
# Fuzzy matching uses two complementary strategies:
# 1. % operator: Fast GIN-index-based matching using server-wide threshold (default 0.3)
# - Catches exact trigram matches quickly via index
# 2. similarity/word_similarity functions: Precise matching with this configurable threshold
# - Catches partial matches that % operator might miss
#
# Value 0.2 chosen based on testing with typical German names:
# - "Müller" vs "Mueller": similarity ~0.65 ✓
# - "Schmidt" vs "Schmitt": similarity ~0.75 ✓
# - "Wagner" vs "Wegner": similarity ~0.55 ✓
# - Random unrelated names: similarity ~0.15 ✗
@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 plus membership_fee_type_id (belongs_to FK)
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
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
# Auto-calculate membership_fee_start_date if not manually set
# Requires both join_date and membership_fee_type_id to be present
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
# Trigger cycle generation after member creation
# Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility
change after_action(fn _changeset, member, _context ->
if member.membership_fee_type_id && member.join_date do
generate_fn = fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _cycles} ->
:ok
{:error, reason} ->
require Logger
Logger.warning(
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
generate_fn.()
else
# Run asynchronously in other environments
Task.start(generate_fn)
end
end
{:ok, member}
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 plus membership_fee_type_id (belongs_to FK)
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
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
# Validate that membership fee type changes only allow same-interval types
change Mv.MembershipFees.Changes.ValidateSameInterval do
where [changing(:membership_fee_type_id)]
end
# Auto-calculate membership_fee_start_date when membership_fee_type_id is set
# and membership_fee_start_date is not already set
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
where [changing(:membership_fee_type_id)]
end
# Trigger cycle regeneration when membership_fee_type_id changes
# This deletes future unpaid cycles and regenerates them with the new type/amount
# Note: Cycle regeneration runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility
change after_action(fn changeset, member, _context ->
fee_type_changed =
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do
regenerate_fn = fn ->
case regenerate_cycles_on_type_change(member) do
:ok ->
:ok
{:error, reason} ->
require Logger
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
regenerate_fn.()
else
# Run asynchronously in other environments
Task.start(regenerate_fn)
end
end
{:ok, member}
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)
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
q2_sanitized = sanitize_search_query(q2)
pat = "%" <> q2_sanitized <> "%"
# Build search filters grouped by search type for maintainability
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
fts_match = build_fts_filter(q2)
substring_match = build_substring_filter(q2_sanitized, pat)
custom_field_match = build_custom_field_filter(pat)
fuzzy_match = build_fuzzy_filter(q2, threshold)
query
|> Ash.Query.filter(
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
)
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
# 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 :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
# Membership fee fields
# membership_fee_start_date: Date from which membership fees should be calculated
# If nil, calculated from join_date + global setting
attribute :membership_fee_start_date, :date do
allow_nil? true
public? true
description "Date from which membership fees should be calculated"
end
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
# Membership fee relationships
# belongs_to: The fee type assigned to this member
# Optional for MVP - can be nil if no fee type assigned yet
belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
allow_nil? true
end
# has_many: All fee cycles for this member
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
end
calculations do
calculate :current_cycle_status, :atom do
description "Status of the current cycle (the one that is active today)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
case get_current_cycle(member) do
nil -> [nil]
cycle -> [cycle.status]
end
end
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :last_cycle_status, :atom do
description "Status of the last completed cycle (the most recent cycle that has ended)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
case get_last_completed_cycle(member) do
nil -> [nil]
cycle -> [cycle.status]
end
end
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :overdue_count, :integer do
description "Count of unpaid cycles that have already ended (cycle_end < today)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
overdue = get_overdue_cycles(member)
count = if is_list(overdue), do: length(overdue), else: 0
[count]
end
end
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
# Helper functions for cycle status calculations
@doc false
def get_current_cycle(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
Enum.find(cycles, &current_cycle?(&1, today))
else
nil
end
end
# Checks if a cycle is the current cycle (active today)
defp current_cycle?(cycle, today) do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
Date.compare(today, cycle_end) in [:lt, :eq]
_ ->
false
end
end
@doc false
def get_last_completed_cycle(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
cycles
|> filter_completed_cycles(today)
|> sort_cycles_by_end_date()
|> List.first()
else
nil
end
end
# Filters cycles that have ended (cycle_end < today)
defp filter_completed_cycles(cycles, today) do
Enum.filter(cycles, fn cycle ->
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) == :gt
_ ->
false
end
end)
end
# Sorts cycles by end date in descending order
defp sort_cycles_by_end_date(cycles) do
Enum.sort_by(
cycles,
fn cycle ->
interval = Map.get(cycle, :membership_fee_type).interval
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
end,
{:desc, Date}
)
end
@doc false
def get_overdue_cycles(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
filter_overdue_cycles(cycles, today)
else
[]
end
end
# Filters cycles that are unpaid and have ended (cycle_end < today)
defp filter_overdue_cycles(cycles, today) do
Enum.filter(cycles, fn cycle ->
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
_ ->
false
end
end)
end
# Regenerates cycles when membership fee type changes
# Deletes future unpaid cycles and regenerates them with the new type/amount
defp regenerate_cycles_on_type_change(member) do
require Ash.Query
today = Date.utc_today()
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
all_unpaid_cycles_query =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
case Ash.read(all_unpaid_cycles_query) do
{:ok, all_unpaid_cycles} ->
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
delete_and_regenerate_cycles(cycles_to_delete, member.id)
{:error, reason} ->
{:error, reason}
end
end
# Filters cycles that haven't ended yet (cycle_end >= today)
# These are the "future" cycles that should be regenerated
defp filter_future_cycles(all_unpaid_cycles, today) do
Enum.filter(all_unpaid_cycles, fn cycle ->
case cycle.membership_fee_type do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) in [:lt, :eq]
_ ->
false
end
end)
end
# Deletes future cycles and regenerates them with the new type/amount
defp delete_and_regenerate_cycles(cycles_to_delete, member_id) do
if Enum.empty?(cycles_to_delete) do
# No cycles to delete, just regenerate
regenerate_cycles(member_id)
else
case delete_cycles(cycles_to_delete) do
:ok -> regenerate_cycles(member_id)
{:error, reason} -> {:error, reason}
end
end
end
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
defp delete_cycles(cycles_to_delete) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle)
end)
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
{:error, :deletion_failed}
else
:ok
end
end
# Regenerates cycles with new type/amount
# CycleGenerator uses its own transaction with advisory lock
defp regenerate_cycles(member_id) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member_id) do
{:ok, _cycles} -> :ok
{:error, reason} -> {:error, reason}
end
end
# 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
## 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
Ash.Query.for_read(query, :search, %{query: q})
end
end
# ============================================================================
# Search Input Sanitization
# ============================================================================
# Sanitizes search input to prevent LIKE pattern injection.
# Escapes SQL LIKE wildcards (% and _) and limits query length.
#
# ## Examples
#
# iex> sanitize_search_query("test%injection")
# "test\\%injection"
#
# iex> sanitize_search_query("very_long_search")
# "very\\_long\\_search"
#
defp sanitize_search_query(query) when is_binary(query) do
query
|> String.slice(0, 100)
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
defp sanitize_search_query(_), do: ""
# ============================================================================
# Search Filter Builders
# ============================================================================
# These functions build search filters grouped by search type for maintainability.
# Priority order: FTS > Substring > Custom Fields > Fuzzy Matching
# Builds full-text search filter using tsvector (highest priority, fastest)
# Uses GIN index on search_vector for optimal performance
defp build_fts_filter(query) do
expr(
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query)
)
end
# Builds substring search filter for structured fields
# Note: contains/2 uses ILIKE '%value%' which is not index-optimized
# Performance: Good for small datasets, may be slow on large tables
defp build_substring_filter(query, _pattern) do
expr(
contains(postal_code, ^query) or
contains(house_number, ^query) or
contains(phone_number, ^query) or
contains(email, ^query) or
contains(city, ^query)
)
end
# Builds search filter for custom field values using ILIKE on JSONB
# Note: ILIKE on JSONB is not index-optimized, may be slow with many custom fields
# This is a fallback for substring matching in custom fields (e.g., phone numbers)
# Uses ->> operator which always returns TEXT directly (no need for -> + ::text fallback)
# Important: `id` must be passed as parameter to correctly reference the outer members table
defp build_custom_field_filter(pattern) do
expr(
fragment(
"EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = ? AND (value->>'_union_value' ILIKE ? OR value->>'value' ILIKE ?))",
id,
^pattern,
^pattern
)
)
end
# Builds fuzzy/trigram matching filter for name and street fields.
# Uses pg_trgm extension with GIN indexes for performance.
#
# Two-tier matching strategy:
# - % operator: Uses server-wide pg_trgm.similarity_threshold (typically 0.3)
# for fast index-based initial filtering
# - similarity/word_similarity: Uses @default_similarity_threshold (0.2)
# for more lenient matching to catch edge cases
#
# Note: Requires trigram GIN indexes on first_name, last_name, street.
defp build_fuzzy_filter(query, threshold) do
expr(
fragment("? % first_name", ^query) or
fragment("? % last_name", ^query) or
fragment("? % street", ^query) or
fragment("word_similarity(?, first_name) > ?", ^query, ^threshold) or
fragment("word_similarity(?, last_name) > ?", ^query, ^threshold) or
fragment("word_similarity(?, street) > ?", ^query, ^threshold) or
fragment("similarity(first_name, ?) > ?", ^query, ^threshold) or
fragment("similarity(last_name, ?) > ?", ^query, ^threshold) or
fragment("similarity(street, ?) > ?", ^query, ^threshold)
)
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
#
# Note: Custom field search is intentionally excluded from linking to optimize
# autocomplete performance. Custom fields are still searchable via the main
# member search which uses the indexed search_vector.
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)
# Sanitize for LIKE patterns (contains uses ILIKE internally)
sanitized_search = sanitize_search_query(trimmed_search)
# Build search filters - excluding custom_field_filter for performance
fts_match = build_fts_filter(trimmed_search)
fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold)
email_substring_match = expr(contains(email, ^sanitized_search))
query
|> Ash.Query.filter(
expr(
# Email exact match has highest priority (for filter_by_email_match)
# If email is "", this is always false and search filters take over
email == ^trimmed_email or
^fts_match or
^fuzzy_match or
^email_substring_match
)
)
else
# No search query: return all unlinked (filter_by_email_match will prioritize email if provided)
query
end
end
end