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 ## 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 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 handle_cycle_generation(member) 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) # 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 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} -> require Logger 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 handle_cycle_generation(member) 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 # SYSTEM OPERATIONS: Allow CRUD operations without actor # In test: All operations allowed (for test fixtures) # In production: Only :create and :read allowed (enforced by NoActor.check) # :read is needed for internal Ash lookups (e.g., relationship validation during user creation). bypass action_type([:create, :read, :update, :destroy]) do description "Allow system operations without actor (seeds, tests, internal lookups)" authorize_if Mv.Authorization.Checks.NoActor end # 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 # GENERAL: Check permissions from user's role # HasPermission handles update permissions correctly: # - :own_data → can update linked member (scope :linked) # - :read_only → cannot update any member (no update permission) # - :normal_user → can update all members (scope :all) # - :admin → can update all members (scope :all) policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role and permission set" 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 # 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 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: [:html_input, :pow]) if changeset2.valid? do :ok else {:error, field: :email, message: "is not a valid email"} end end # Validate required custom fields validate fn changeset, _ -> provided_values = provided_custom_field_values(changeset) case Mv.Membership.list_required_custom_fields() 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, 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 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 def regenerate_cycles_on_type_change(member) do 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 defp do_regenerate_cycles_on_type_change(member, today, opts) do require Ash.Query skip_lock? = Keyword.get(opts, :skip_lock?, false) # 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, today, 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} defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, 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) 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 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 # 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 defp handle_cycle_generation(member) do if Mv.Config.sql_sandbox?() do handle_cycle_generation_sync(member) else handle_cycle_generation_async(member) end end # Runs cycle generation synchronously (for test environment) defp handle_cycle_generation_sync(member) do require Logger case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( member.id, today: Date.utc_today() ) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) log_cycle_generation_success(member, cycles, notifications, sync: true) {:error, reason} -> log_cycle_generation_error(member, reason, sync: true) end end # Runs cycle generation asynchronously (for production environment) defp handle_cycle_generation_async(member) do Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) log_cycle_generation_success(member, cycles, notifications, sync: false) {:error, reason} -> log_cycle_generation_error(member, reason, sync: false) end end) 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?) do require Logger sync_label = if sync?, do: "", else: " (async)" Logger.debug( "Successfully generated cycles for member#{sync_label}", 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?) do require Logger sync_label = if sync?, do: "", else: " (async)" Logger.error( "Failed to generate cycles for member#{sync_label}", member_id: member.id, member_email: member.email, error: inspect(reason), error_type: error_type(reason) ) end # 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) 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) do case Ash.load(member_data, :custom_field_values) 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