UnrelateUserWhenArgumentNil used User :update which only accepts :email. Switch to :update_user with member: nil so manage_relationship clears member_id.
1389 lines
50 KiB
Elixir
1389 lines
50 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, 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: email (all other fields are optional)
|
||
- Email format validation (using EctoCommons.EmailValidator)
|
||
- 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
|
||
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
|
||
|
||
## 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 city, etc.).
|
||
"""
|
||
use Ash.Resource,
|
||
domain: Mv.Membership,
|
||
data_layer: AshPostgres.DataLayer,
|
||
authorizers: [Ash.Policy.Authorizer]
|
||
|
||
require Ash.Query
|
||
import Ash.Expr
|
||
alias Mv.Helpers
|
||
require Logger
|
||
|
||
alias Mv.Membership.Helpers.VisibilityConfig
|
||
|
||
# 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
|
||
|
||
# Note: Custom validation function cannot be done atomically (queries DB for required custom fields)
|
||
# In Ash 3.0, require_atomic? is not available for create actions, but the validation will still work
|
||
# 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-assign default membership fee type if not explicitly set
|
||
change Mv.Membership.Member.Changes.SetDefaultMembershipFeeType
|
||
|
||
# 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_transaction(fn changeset, result, _context ->
|
||
case result do
|
||
{:ok, member} ->
|
||
if member.membership_fee_type_id && member.join_date do
|
||
# Capture initiator for audit trail (if available)
|
||
initiator = Map.get(changeset.context, :actor)
|
||
handle_cycle_generation(member, initiator: initiator)
|
||
end
|
||
|
||
{:error, _} ->
|
||
:ok
|
||
end
|
||
|
||
result
|
||
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)
|
||
|
||
# When :user argument is present and nil/empty, unrelate (admin-only via policy).
|
||
# Must run before manage_relationship; on_missing: :ignore then does nothing for nil input.
|
||
change Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil
|
||
|
||
# Manage the user relationship during member update
|
||
# on_missing: :ignore so that omitting :user does NOT unlink (security: only admins may
|
||
# change the link; unlink is explicit via user: nil, forbidden for non-admins by policy).
|
||
change manage_relationship(:user, :user,
|
||
on_lookup: :relate,
|
||
on_no_match: :error,
|
||
on_match: :error,
|
||
on_missing: :ignore
|
||
)
|
||
|
||
# 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 synchronously in the same transaction to ensure atomicity
|
||
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
||
# Notifications are returned to Ash and sent automatically after commit
|
||
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
|
||
case regenerate_cycles_on_type_change(member) do
|
||
{:ok, notifications} ->
|
||
# Return notifications to Ash - they will be sent automatically after commit
|
||
{:ok, member, notifications}
|
||
|
||
{:error, reason} ->
|
||
Logger.warning(
|
||
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
|
||
)
|
||
|
||
{:ok, member}
|
||
end
|
||
else
|
||
{:ok, member}
|
||
end
|
||
end)
|
||
|
||
# Trigger cycle regeneration when join_date or exit_date changes
|
||
# Regenerates cycles based on new dates
|
||
# Note: Cycle generation runs synchronously in test environment, asynchronously in production
|
||
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
||
# If both join_date and exit_date are changed simultaneously, this hook runs only once
|
||
# (Ash ensures each after_transaction hook runs once per action, regardless of how many attributes changed)
|
||
change after_transaction(fn changeset, result, _context ->
|
||
case result do
|
||
{:ok, member} ->
|
||
join_date_changed = Ash.Changeset.changing_attribute?(changeset, :join_date)
|
||
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
|
||
|
||
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
|
||
# Capture initiator for audit trail (if available)
|
||
initiator = Map.get(changeset.context, :actor)
|
||
handle_cycle_generation(member, initiator: initiator)
|
||
end
|
||
|
||
{:error, _} ->
|
||
:ok
|
||
end
|
||
|
||
result
|
||
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
|
||
|
||
# Authorization Policies
|
||
# Order matters: Most specific policies first, then general permission check
|
||
policies do
|
||
# SPECIAL CASE: Users can always READ their linked member
|
||
# This allows users with ANY permission set to read their own linked member
|
||
# Check using the inverse relationship: User.member_id → Member.id
|
||
bypass action_type(:read) do
|
||
description "Users can always read member linked to their account"
|
||
authorize_if expr(id == ^actor(:member_id))
|
||
end
|
||
|
||
# READ/DESTROY: Check permissions only (no :user argument on these actions)
|
||
policy action_type([:read, :destroy]) do
|
||
description "Check permissions from user's role"
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
end
|
||
|
||
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
|
||
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
||
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
|
||
policy action_type([:create, :update]) do
|
||
description "Forbid user link unless admin; then check permissions"
|
||
forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
end
|
||
|
||
# DEFAULT: Forbid if no policy matched
|
||
# Ash implicitly forbids if no policy authorized
|
||
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
|
||
|
||
# Email is required
|
||
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
|
||
|
||
# Only admins or the linked user may change a linked member's email (prevents breaking sync)
|
||
validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]
|
||
|
||
# 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
|
||
|
||
# Get actor from changeset context (may be nil)
|
||
actor = Map.get(changeset.context || %{}, :actor)
|
||
|
||
# Check if authorization is disabled in the parent operation's context
|
||
# Access private context where authorize? flag is stored
|
||
authorize? =
|
||
case get_in(changeset.context, [:private, :authorize?]) do
|
||
false -> false
|
||
_ -> true
|
||
end
|
||
|
||
# Use actor for authorization when available and authorize? is true
|
||
# Fall back to authorize?: false only for bootstrap/system operations
|
||
# This ensures normal operations respect authorization while system operations work
|
||
query_opts =
|
||
if actor && authorize? do
|
||
[actor: actor]
|
||
else
|
||
[authorize?: false]
|
||
end
|
||
|
||
case Ash.get(Mv.Accounts.User, user_id, query_opts) 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, %Ash.Error.Query.NotFound{}} ->
|
||
{:error, field: :user, message: "User not found"}
|
||
|
||
{:error, _} ->
|
||
{:error, field: :user, message: "User not found"}
|
||
end
|
||
else
|
||
:ok
|
||
end
|
||
end
|
||
|
||
# Join date not in 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"
|
||
|
||
# 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: Mv.Constants.email_validator_checks()
|
||
)
|
||
|
||
if changeset2.valid? do
|
||
:ok
|
||
else
|
||
{:error, field: :email, message: "is not a valid email"}
|
||
end
|
||
end
|
||
|
||
# Validate required custom fields (actor from validation context only; no fallback)
|
||
validate fn changeset, context ->
|
||
provided_values = provided_custom_field_values(changeset)
|
||
actor = context.actor
|
||
|
||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||
{:ok, required_custom_fields} ->
|
||
missing_fields = missing_required_fields(required_custom_fields, provided_values)
|
||
|
||
if Enum.empty?(missing_fields) do
|
||
:ok
|
||
else
|
||
build_custom_field_validation_error(missing_fields)
|
||
end
|
||
|
||
{:error, %Ash.Error.Forbidden{}} ->
|
||
Logger.warning(
|
||
"Required custom fields validation: actor not authorized to read CustomField"
|
||
)
|
||
|
||
{:error,
|
||
field: :custom_field_values,
|
||
message:
|
||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||
|
||
{:error, :missing_actor} ->
|
||
Logger.warning("Required custom fields validation: no actor in context")
|
||
|
||
{:error,
|
||
field: :custom_field_values,
|
||
message:
|
||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||
|
||
{:error, error} ->
|
||
Logger.error(
|
||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||
)
|
||
|
||
{:error,
|
||
field: :custom_field_values,
|
||
message:
|
||
"Unable to validate required custom fields. Please try again or contact support."}
|
||
end
|
||
end
|
||
end
|
||
|
||
attributes do
|
||
uuid_v7_primary_key :id
|
||
|
||
attribute :first_name, :string do
|
||
allow_nil? true
|
||
constraints min_length: 1
|
||
end
|
||
|
||
attribute :last_name, :string do
|
||
allow_nil? true
|
||
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 :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
|
||
|
||
# Groups relationships
|
||
# has_many: All member-group associations for this member
|
||
has_many :member_groups, Mv.Membership.MemberGroup
|
||
# many_to_many: All groups this member belongs to (through MemberGroup)
|
||
many_to_many :groups, Mv.Membership.Group, through: Mv.Membership.MemberGroup
|
||
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
|
||
# exit_date defaults to false (hidden) instead of true
|
||
default_visibility = if field == :exit_date, do: false, else: true
|
||
|
||
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 = VisibilityConfig.normalize(visibility_config)
|
||
|
||
# Get value from normalized config, use field-specific default
|
||
Map.get(normalized_config, field, default_visibility)
|
||
|
||
{:error, _} ->
|
||
# If settings can't be loaded, use field-specific default
|
||
default_visibility
|
||
end
|
||
end
|
||
|
||
def show_in_overview?(_), do: true
|
||
|
||
# Helper functions for cycle status calculations
|
||
#
|
||
# These functions expect membership_fee_cycles to be loaded with membership_fee_type
|
||
# preloaded. The calculations explicitly load this relationship, but if called
|
||
# directly, ensure membership_fee_type is loaded or the functions will return
|
||
# nil/[] when membership_fee_type is missing.
|
||
|
||
@doc false
|
||
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||
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, ¤t_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
|
||
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
||
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
|
||
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
||
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
|
||
# Uses advisory lock to prevent concurrent modifications
|
||
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
||
# to be sent after transaction commits
|
||
@doc false
|
||
# Uses system actor for cycle regeneration (mandatory side effect)
|
||
def regenerate_cycles_on_type_change(member, _opts \\ []) do
|
||
alias Mv.Helpers
|
||
alias Mv.Helpers.SystemActor
|
||
|
||
today = Date.utc_today()
|
||
lock_key = :erlang.phash2(member.id)
|
||
|
||
# Use advisory lock to prevent concurrent deletion and regeneration
|
||
# This ensures atomicity when multiple updates happen simultaneously
|
||
if Mv.Repo.in_transaction?() do
|
||
regenerate_cycles_in_transaction(member, today, lock_key)
|
||
else
|
||
regenerate_cycles_new_transaction(member, today, lock_key)
|
||
end
|
||
end
|
||
|
||
# Already in transaction: use advisory lock directly
|
||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||
end
|
||
|
||
# Not in transaction: start new transaction with advisory lock
|
||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||
Mv.Repo.transaction(fn ->
|
||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||
|
||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||
{:ok, notifications} ->
|
||
# Return notifications - they will be sent by the caller
|
||
notifications
|
||
|
||
{:error, reason} ->
|
||
Mv.Repo.rollback(reason)
|
||
end
|
||
end)
|
||
|> case do
|
||
{:ok, notifications} -> {:ok, notifications}
|
||
{:error, reason} -> {:error, reason}
|
||
end
|
||
end
|
||
|
||
# Performs the actual cycle deletion and regeneration
|
||
# Returns {:ok, notifications} or {:error, reason}
|
||
# notifications are collected to be sent after transaction commits
|
||
# Uses system actor for all operations
|
||
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
||
alias Mv.Helpers
|
||
alias Mv.Helpers.SystemActor
|
||
|
||
require Ash.Query
|
||
|
||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||
system_actor = SystemActor.get_system_actor()
|
||
actor_opts = Helpers.ash_actor_opts(system_actor)
|
||
|
||
# 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, actor_opts) do
|
||
{:ok, all_unpaid_cycles} ->
|
||
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
||
|
||
delete_and_regenerate_cycles(
|
||
cycles_to_delete,
|
||
member.id,
|
||
today,
|
||
actor_opts,
|
||
skip_lock?: skip_lock?
|
||
)
|
||
|
||
{: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
|
||
# Passes today to ensure consistent date across deletion and regeneration
|
||
# Returns {:ok, notifications} or {:error, reason}
|
||
# Uses system actor for cycle generation and deletion
|
||
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, actor_opts, opts) do
|
||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||
|
||
if Enum.empty?(cycles_to_delete) do
|
||
# No cycles to delete, just regenerate
|
||
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||
else
|
||
case delete_cycles(cycles_to_delete, actor_opts) do
|
||
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||
{:error, reason} -> {:error, reason}
|
||
end
|
||
end
|
||
end
|
||
|
||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
||
# Uses system actor for authorization to ensure deletion always works
|
||
defp delete_cycles(cycles_to_delete, actor_opts) do
|
||
delete_results =
|
||
Enum.map(cycles_to_delete, fn cycle ->
|
||
Ash.destroy(cycle, actor_opts)
|
||
end)
|
||
|
||
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||
{:error, :deletion_failed}
|
||
else
|
||
:ok
|
||
end
|
||
end
|
||
|
||
# Regenerates cycles with new type/amount
|
||
# Passes today to ensure consistent date across deletion and regeneration
|
||
# skip_lock?: true means advisory lock is already set by caller
|
||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||
defp regenerate_cycles(member_id, today, opts) do
|
||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||
|
||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||
member_id,
|
||
today: today,
|
||
skip_lock?: skip_lock?
|
||
) do
|
||
{:ok, _cycles, notifications} when is_list(notifications) ->
|
||
{:ok, notifications}
|
||
|
||
{:error, reason} ->
|
||
{:error, reason}
|
||
end
|
||
end
|
||
|
||
# Handles cycle generation for a member, choosing sync or async execution
|
||
# based on environment (test vs production)
|
||
# This function encapsulates the common logic for cycle generation
|
||
# to avoid code duplication across different hooks
|
||
# Uses system actor for cycle generation (mandatory side effect)
|
||
# Captures initiator for audit trail (if available in opts)
|
||
defp handle_cycle_generation(member, opts) do
|
||
initiator = Keyword.get(opts, :initiator)
|
||
|
||
if Mv.Config.sql_sandbox?() do
|
||
handle_cycle_generation_sync(member, initiator)
|
||
else
|
||
handle_cycle_generation_async(member, initiator)
|
||
end
|
||
end
|
||
|
||
# Runs cycle generation synchronously (for test environment)
|
||
defp handle_cycle_generation_sync(member, initiator) do
|
||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||
member.id,
|
||
today: Date.utc_today(),
|
||
initiator: initiator
|
||
) do
|
||
{:ok, cycles, notifications} ->
|
||
send_notifications_if_any(notifications)
|
||
|
||
log_cycle_generation_success(member, cycles, notifications,
|
||
sync: true,
|
||
initiator: initiator
|
||
)
|
||
|
||
{:error, reason} ->
|
||
log_cycle_generation_error(member, reason, sync: true, initiator: initiator)
|
||
end
|
||
end
|
||
|
||
# Runs cycle generation asynchronously (for production environment)
|
||
defp handle_cycle_generation_async(member, initiator) do
|
||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
|
||
initiator: initiator
|
||
) do
|
||
{:ok, cycles, notifications} ->
|
||
send_notifications_if_any(notifications)
|
||
|
||
log_cycle_generation_success(member, cycles, notifications,
|
||
sync: false,
|
||
initiator: initiator
|
||
)
|
||
|
||
{:error, reason} ->
|
||
log_cycle_generation_error(member, reason, sync: false, initiator: initiator)
|
||
end
|
||
end)
|
||
|> Task.await(:infinity)
|
||
end
|
||
|
||
# Sends notifications if any are present
|
||
defp send_notifications_if_any(notifications) do
|
||
if Enum.any?(notifications) do
|
||
Ash.Notifier.notify(notifications)
|
||
end
|
||
end
|
||
|
||
# Logs successful cycle generation
|
||
defp log_cycle_generation_success(member, cycles, notifications,
|
||
sync: sync?,
|
||
initiator: initiator
|
||
) do
|
||
sync_label = if sync?, do: "", else: " (async)"
|
||
initiator_info = get_initiator_info(initiator)
|
||
|
||
Logger.debug(
|
||
"Successfully generated cycles for member#{sync_label} (initiator: #{initiator_info})",
|
||
member_id: member.id,
|
||
cycles_count: length(cycles),
|
||
notifications_count: length(notifications)
|
||
)
|
||
end
|
||
|
||
# Logs cycle generation errors
|
||
defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do
|
||
sync_label = if sync?, do: "", else: " (async)"
|
||
initiator_info = get_initiator_info(initiator)
|
||
|
||
Logger.error(
|
||
"Failed to generate cycles for member#{sync_label} (initiator: #{initiator_info})",
|
||
member_id: member.id,
|
||
member_email: member.email,
|
||
error: inspect(reason),
|
||
error_type: error_type(reason)
|
||
)
|
||
end
|
||
|
||
# Extracts initiator information for audit trail
|
||
defp get_initiator_info(nil), do: "system"
|
||
defp get_initiator_info(%{email: email}), do: email
|
||
defp get_initiator_info(_), do: "unknown"
|
||
|
||
# Helper to extract error type for structured logging
|
||
defp error_type(%{__struct__: struct_name}), do: struct_name
|
||
defp error_type(error) when is_atom(error), do: error
|
||
defp error_type(_), do: :unknown
|
||
|
||
@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(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
|
||
|
||
# Extracts provided custom field values from changeset
|
||
# Handles both create (from argument) and update (from existing data) scenarios
|
||
defp provided_custom_field_values(changeset) do
|
||
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
|
||
|
||
if is_nil(custom_field_values_arg) do
|
||
extract_existing_values(changeset.data, changeset)
|
||
else
|
||
extract_argument_values(custom_field_values_arg)
|
||
end
|
||
end
|
||
|
||
# Extracts custom field values from existing member data (update scenario)
|
||
defp extract_existing_values(member_data, changeset) do
|
||
actor = Map.get(changeset.context, :actor)
|
||
opts = Helpers.ash_actor_opts(actor)
|
||
|
||
case Ash.load(member_data, :custom_field_values, opts) do
|
||
{:ok, %{custom_field_values: existing_values}} ->
|
||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||
|
||
_ ->
|
||
%{}
|
||
end
|
||
end
|
||
|
||
# Extracts value from a CustomFieldValue struct
|
||
defp extract_value_from_cfv(cfv, acc) do
|
||
value = extract_union_value(cfv.value)
|
||
Map.put(acc, cfv.custom_field_id, value)
|
||
end
|
||
|
||
# Extracts value from union type (map or direct value)
|
||
defp extract_union_value(value) when is_map(value), do: Map.get(value, :value)
|
||
defp extract_union_value(value), do: value
|
||
|
||
# Extracts custom field values from provided argument (create/update scenario)
|
||
defp extract_argument_values(custom_field_values_arg) do
|
||
Enum.reduce(custom_field_values_arg, %{}, &extract_value_from_arg/2)
|
||
end
|
||
|
||
# Extracts value from argument map
|
||
defp extract_value_from_arg(cfv, acc) do
|
||
custom_field_id = Map.get(cfv, "custom_field_id")
|
||
value_map = Map.get(cfv, "value", %{})
|
||
actual_value = extract_value_from_map(value_map)
|
||
Map.put(acc, custom_field_id, actual_value)
|
||
end
|
||
|
||
# Extracts value from map, supporting both "value" and "_union_value" keys
|
||
# Also handles Ash.Union structs (which have atom keys :value and :type)
|
||
# Uses cond instead of || to preserve false values
|
||
defp extract_value_from_map(value_map) do
|
||
cond do
|
||
# Handle Ash.Union struct - check if it's a struct with __struct__ == Ash.Union
|
||
match?({:ok, Ash.Union}, Map.fetch(value_map, :__struct__)) ->
|
||
Map.get(value_map, :value)
|
||
|
||
# Handle map with string keys
|
||
Map.has_key?(value_map, "value") ->
|
||
Map.get(value_map, "value")
|
||
|
||
Map.has_key?(value_map, "_union_value") ->
|
||
Map.get(value_map, "_union_value")
|
||
|
||
# Handle map with atom keys
|
||
Map.has_key?(value_map, :value) ->
|
||
Map.get(value_map, :value)
|
||
|
||
true ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
# Finds which required custom fields are missing from provided values
|
||
defp missing_required_fields(required_custom_fields, provided_values) do
|
||
Enum.filter(required_custom_fields, fn cf ->
|
||
value = Map.get(provided_values, cf.id)
|
||
not value_present?(value, cf.value_type)
|
||
end)
|
||
end
|
||
|
||
# Builds validation error message for missing required custom fields
|
||
defp build_custom_field_validation_error(missing_fields) do
|
||
# Sort missing fields alphabetically for consistent error messages
|
||
sorted_missing_fields = Enum.sort_by(missing_fields, & &1.name)
|
||
missing_names = Enum.map_join(sorted_missing_fields, ", ", & &1.name)
|
||
|
||
{:error,
|
||
field: :custom_field_values,
|
||
message:
|
||
Gettext.dgettext(MvWeb.Gettext, "default", "Required custom fields missing: %{fields}",
|
||
fields: missing_names
|
||
)}
|
||
end
|
||
|
||
# Helper function to check if a value is present for a given custom field type
|
||
# Boolean: false is valid, only nil is invalid
|
||
# String: nil or empty strings are invalid
|
||
# Integer: nil or empty strings are invalid, 0 is valid
|
||
# Date: nil or empty strings are invalid
|
||
# Email: nil or empty strings are invalid
|
||
defp value_present?(nil, _type), do: false
|
||
|
||
defp value_present?(value, :boolean), do: not is_nil(value)
|
||
|
||
defp value_present?(value, :string), do: is_binary(value) and String.trim(value) != ""
|
||
|
||
defp value_present?(value, :integer) when is_integer(value), do: true
|
||
|
||
defp value_present?(value, :integer) when is_binary(value), do: String.trim(value) != ""
|
||
|
||
defp value_present?(_value, :integer), do: false
|
||
|
||
defp value_present?(value, :date) when is_struct(value, Date), do: true
|
||
|
||
defp value_present?(value, :date) when is_binary(value), do: String.trim(value) != ""
|
||
|
||
defp value_present?(_value, :date), do: false
|
||
|
||
defp value_present?(value, :email) when is_binary(value), do: String.trim(value) != ""
|
||
|
||
defp value_present?(_value, :email), do: false
|
||
|
||
defp value_present?(_value, _type), do: false
|
||
end
|