defmodule Mv.Membership.Member do @moduledoc """ Ash resource representing a club member. ## Overview Members are the core entity in the membership management system. Each member can have: - Personal information (name, email, phone, address) - Optional link to a User account (1:1 relationship) - Dynamic custom field values via CustomField system - Full-text searchable profile ## Email Synchronization When a member is linked to a user account, emails are automatically synchronized bidirectionally. User.email is the source of truth on initial link. See `Mv.EmailSync` for details. ## Relationships - `has_many :custom_field_values` - Dynamic custom fields - `has_one :user` - Optional authentication account link ## Validations - Required: first_name, last_name, email - Email format validation (using EctoCommons.EmailValidator) - Phone number format: international format with 6-20 digits - Postal code format: exactly 5 digits (German format) - Date validations: join_date not in future, exit_date after join_date - Email uniqueness: prevents conflicts with unlinked users ## Full-Text Search Members have a `search_vector` attribute (tsvector) that is automatically updated via database trigger. Search includes name, email, notes, contact fields, and all custom field values. Custom field values are automatically included in the search vector with weight 'C' (same as phone_number, city, etc.). """ use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer require Ash.Query import Ash.Expr # Module constants @member_search_limit 10 @default_similarity_threshold 0.2 # Use constants from Mv.Constants for member fields # This ensures consistency across the codebase @member_fields Mv.Constants.member_fields() postgres do table "members" repo Mv.Repo end actions do defaults [:read, :destroy] create :create_member do primary? true # Custom field values can be created along with member argument :custom_field_values, {:array, :map} # Allow user to be passed as argument for relationship management # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true accept @member_fields change manage_relationship(:custom_field_values, type: :create) # Manage the user relationship during member creation change manage_relationship(:user, :user, # Look up existing user and relate to it on_lookup: :relate, # Error if user doesn't exist in database on_no_match: :error, # Error if user is already linked to another member (prevents "stealing") on_match: :error, # If no user provided, that's fine (optional relationship) on_missing: :ignore ) # Sync user email to member when linking (User → Member) # Only runs when user relationship is being changed change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:user)] end end update :update_member do primary? true # Required because custom validation function cannot be done atomically require_atomic? false # Custom field values can be updated or created along with member argument :custom_field_values, {:array, :map} # Allow user to be passed as argument for relationship management # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true accept @member_fields change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) # Manage the user relationship during member update change manage_relationship(:user, :user, # Look up existing user and relate to it on_lookup: :relate, # Error if user doesn't exist in database on_no_match: :error, # Error if user is already linked to another member (prevents "stealing") on_match: :error, # If no user provided, remove existing relationship (allows user removal) on_missing: :unrelate ) # Sync member email to user when email changes (Member → User) # Only runs when email is being changed change Mv.EmailSync.Changes.SyncMemberEmailToUser do where [changing(:email)] end # Sync user email to member when linking (User → Member) # Only runs when user relationship is being changed change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:user)] end end # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true argument :similarity_threshold, :float, allow_nil?: true prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" # Use default similarity threshold if not provided # Lower value leads to more results but also more unspecific results threshold = Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) pat = "%" <> q2 <> "%" # Build search filters grouped by search type for maintainability # Priority: FTS > Substring > Custom Fields > Fuzzy Matching fts_match = build_fts_filter(q2) substring_match = build_substring_filter(q2, pat) custom_field_match = build_custom_field_filter(pat) fuzzy_match = build_fuzzy_filter(q2, threshold) query |> Ash.Query.filter( expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) ) else query end end end # Action to find members available for linking to a user account # Returns only unlinked members (user_id == nil), limited to 10 results # # Filtering behavior: # - If search_query provided: fuzzy search on names and email # - If no search_query: return all unlinked members (up to limit) # - user_email should be handled by caller with filter_by_email_match/2 read :available_for_linking do argument :user_email, :string, allow_nil?: true argument :search_query, :string, allow_nil?: true prepare fn query, _ctx -> user_email = Ash.Query.get_argument(query, :user_email) search_query = Ash.Query.get_argument(query, :search_query) query |> Ash.Query.filter(is_nil(user)) |> apply_linking_filters(user_email, search_query) |> Ash.Query.limit(@member_search_limit) end end end @doc """ Filters members list based on email match priority. Priority logic: 1. If email matches a member: return ONLY that member (highest priority) 2. If email doesn't match: return all members (for display in dropdown) This is used with :available_for_linking action to implement email-priority behavior: - user_email matches → Only this member - user_email does NOT match + NO search_query → All unlinked members - user_email does NOT match + search_query provided → search_query filtered members ## Parameters - `members` - List of Member structs (from :available_for_linking action) - `user_email` - Email string to match against member emails ## Returns - List of Member structs (either single email match or all members) ## Examples iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] iex> filter_by_email_match(members, "test@example.com") [%Member{email: "test@example.com"}] iex> filter_by_email_match(members, "nomatch@example.com") [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] """ @spec filter_by_email_match([t()], String.t()) :: [t()] def filter_by_email_match(members, user_email) when is_list(members) and is_binary(user_email) do email_match = Enum.find(members, &(&1.email == user_email)) if email_match do # Email match found - return only this member (highest priority) [email_match] else # No email match - return all members unchanged members end end @spec filter_by_email_match(any(), any()) :: any() def filter_by_email_match(members, _user_email), do: members validations do # Required fields are covered by allow_nil? false # First name and last name must not be empty validate present(:first_name) validate present(:last_name) validate present(:email) # Email uniqueness check for all actions that change the email attribute # Validates that member email is not already used by another (unlinked) user validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser # Prevent linking to a user that already has a member # This validation prevents "stealing" users from other members by checking # if the target user is already linked to a different member # This is necessary because manage_relationship's on_match: :error only checks # if the user is already linked to THIS specific member, not ANY member validate fn changeset, _context -> user_arg = Ash.Changeset.get_argument(changeset, :user) if user_arg && user_arg[:id] do user_id = user_arg[:id] current_member_id = changeset.data.id # Check the current state of the user in the database case Ash.get(Mv.Accounts.User, user_id) do # User is free to be linked {:ok, %{member_id: nil}} -> :ok # User already linked to this member (update scenario) {:ok, %{member_id: ^current_member_id}} -> :ok {:ok, %{member_id: _other_member_id}} -> # User is linked to a different member - prevent "stealing" {:error, field: :user, message: "User is already linked to another member"} {:error, _} -> {:error, field: :user, message: "User not found"} end else :ok end end # Join date not in the future validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:join_date)], message: "cannot be in the future" # Exit date not before join date validate compare(:exit_date, greater_than: :join_date), where: [present([:join_date, :exit_date])], message: "cannot be before join date" # Phone number format (only if set) validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/), where: [present(:phone_number)], message: "is not a valid phone number" # Postal code format (only if set) validate match(:postal_code, ~r/^\d{5}$/), where: [present(:postal_code)], message: "must consist of 5 digits" # Email validation with EctoCommons.EmailValidator validate fn changeset, _ -> email = Ash.Changeset.get_attribute(changeset, :email) changeset2 = {%{}, %{email: :string}} |> Ecto.Changeset.cast(%{email: email}, [:email]) |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) if changeset2.valid? do :ok else {:error, field: :email, message: "is not a valid email"} end end end attributes do uuid_v7_primary_key :id attribute :first_name, :string do allow_nil? false constraints min_length: 1 end attribute :last_name, :string do allow_nil? false constraints min_length: 1 end # IMPORTANT: Email Synchronization # When member and user are linked, emails are automatically synced bidirectionally. # User.email is the source of truth - when a link is established, member.email # is overridden to match user.email. Subsequent changes to either email will # sync to the other resource. # See: Mv.EmailSync.Changes.SyncUserEmailToMember # Mv.EmailSync.Changes.SyncMemberEmailToUser attribute :email, :string do allow_nil? false constraints min_length: 5, max_length: 254 end attribute :paid, :boolean do allow_nil? true end attribute :phone_number, :string do allow_nil? true end attribute :join_date, :date do allow_nil? true end attribute :exit_date, :date do allow_nil? true end attribute :notes, :string do allow_nil? true end attribute :city, :string do allow_nil? true end attribute :street, :string do allow_nil? true end attribute :house_number, :string do allow_nil? true end attribute :postal_code, :string do allow_nil? true end attribute :search_vector, AshPostgres.Tsvector, writable?: false, public?: false, select_by_default?: false end relationships do has_many :custom_field_values, Mv.Membership.CustomFieldValue # 1:1 relationship - Member can optionally have one User # This references the User's member_id attribute # The relationship is optional (allow_nil? true by default) has_one :user, Mv.Accounts.User end # Define identities for upsert operations identities do identity :unique_email, [:email] end @doc """ Checks if a member field should be shown in the overview. Reads the visibility configuration from Settings resource. If a field is not configured in settings, it defaults to `true` (visible). ## Parameters - `field` - Atom representing the member field name (e.g., `:email`, `:street`) ## Returns - `true` if the field should be shown in overview (default) - `false` if the field is configured as hidden in settings ## Examples iex> Member.show_in_overview?(:email) true iex> Member.show_in_overview?(:street) true # or false if configured in settings """ @spec show_in_overview?(atom()) :: boolean() def show_in_overview?(field) when is_atom(field) do case Mv.Membership.get_settings() do {:ok, settings} -> visibility_config = settings.member_field_visibility || %{} # Normalize map keys to atoms (JSONB may return string keys) normalized_config = normalize_visibility_config(visibility_config) # Get value from normalized config, default to true Map.get(normalized_config, field, true) {:error, _} -> # If settings can't be loaded, default to visible true end end def show_in_overview?(_), do: true # Normalizes visibility config map keys from strings to atoms. # JSONB in PostgreSQL converts atom keys to string keys when storing. defp normalize_visibility_config(config) when is_map(config) do Enum.reduce(config, %{}, fn {key, value}, acc when is_atom(key) -> Map.put(acc, key, value) {key, value}, acc when is_binary(key) -> try do atom_key = String.to_existing_atom(key) Map.put(acc, atom_key, value) rescue ArgumentError -> acc end _, acc -> acc end) end defp normalize_visibility_config(_), do: %{} @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. Wraps the `:search` action with convenient opts-based argument passing. Searches across first_name, last_name, email, and other text fields using full-text search combined with trigram similarity. ## Parameters - `query` - Ash.Query.t() to apply search to - `opts` - Keyword list or map with search options: - `:query` or `"query"` - Search string - `:fields` or `"fields"` - Optional field restrictions ## Returns - Modified Ash.Query.t() with search filters applied ## Examples iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!() [%Member{first_name: "Greta", ...}] iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant [%Member{first_name: "Greta", ...}] """ @spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t() def fuzzy_search(query, opts) do q = (opts[:query] || opts["query"] || "") |> to_string() if String.trim(q) == "" do query else args = case opts[:fields] || opts["fields"] do nil -> %{query: q} fields -> %{query: q, fields: fields} end Ash.Query.for_read(query, :search, args) end end # ============================================================================ # Search Filter Builders # ============================================================================ # These functions build search filters grouped by search type for maintainability. # Priority order: FTS > Substring > Custom Fields > Fuzzy Matching # Builds full-text search filter using tsvector (highest priority, fastest) # Uses GIN index on search_vector for optimal performance defp build_fts_filter(query) do expr( fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query) ) end # Builds substring search filter for structured fields # Note: contains/2 uses ILIKE '%value%' which is not index-optimized # Performance: Good for small datasets, may be slow on large tables defp build_substring_filter(query, pattern) do expr( contains(postal_code, ^query) or contains(house_number, ^query) or contains(phone_number, ^query) or contains(email, ^query) or contains(city, ^query) or ilike(city, ^pattern) ) end # Builds search filter for custom field values using LIKE on JSONB # Note: LIKE 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) defp build_custom_field_filter(pattern) do expr( fragment( "EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND (value->>'_union_value' LIKE ? OR value->>'value' LIKE ? OR (value->'_union_value')::text LIKE ? OR (value->'value')::text LIKE ?))", ^pattern, ^pattern, ^pattern, ^pattern ) ) end # Builds fuzzy/trigram matching filter for name and street fields # Uses pg_trgm extension with GIN indexes for performance # Note: Requires trigram 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 # # Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires # multiple OR conditions for good search quality (FTS + trigram similarity + substring) # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp apply_linking_filters(query, user_email, search_query) do has_search = search_query && String.trim(search_query) != "" # Use empty string instead of nil to simplify filter logic trimmed_email = if user_email, do: String.trim(user_email), else: "" if has_search do # Search query provided: return email-match OR fuzzy-search candidates trimmed_search = String.trim(search_query) pat = "%" <> trimmed_search <> "%" # Build search filters using modular functions for maintainability fts_match = build_fts_filter(trimmed_search) custom_field_match = build_custom_field_filter(pat) fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold) email_substring_match = expr(contains(email, ^trimmed_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 ^custom_field_match or ^fuzzy_match or ^email_substring_match ) ) else # No search query: return all unlinked (filter_by_email_match will prioritize email if provided) query end end end