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, and contact fields. """ use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer require Ash.Query import Ash.Expr # Module constants @member_search_limit 10 @default_similarity_threshold 0.2 # Use constants from Mv.Constants for member fields # This ensures consistency across the codebase @member_fields Mv.Constants.member_fields() postgres do table "members" repo Mv.Repo end actions do defaults [:read, :destroy] create :create_member do primary? true # Custom field values can be created along with member argument :custom_field_values, {:array, :map} # Allow user to be passed as argument for relationship management # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true accept @member_fields change manage_relationship(:custom_field_values, type: :create) # Manage the user relationship during member creation change manage_relationship(:user, :user, # Look up existing user and relate to it on_lookup: :relate, # Error if user doesn't exist in database on_no_match: :error, # Error if user is already linked to another member (prevents "stealing") on_match: :error, # If no user provided, that's fine (optional relationship) on_missing: :ignore ) # Sync user email to member when linking (User → Member) # Only runs when user relationship is being changed change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:user)] end end update :update_member do primary? true # Required because custom validation function cannot be done atomically require_atomic? false # Custom field values can be updated or created along with member argument :custom_field_values, {:array, :map} # Allow user to be passed as argument for relationship management # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true accept @member_fields change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) # Manage the user relationship during member update change manage_relationship(:user, :user, # Look up existing user and relate to it on_lookup: :relate, # Error if user doesn't exist in database on_no_match: :error, # Error if user is already linked to another member (prevents "stealing") on_match: :error, # If no user provided, remove existing relationship (allows user removal) on_missing: :unrelate ) # Sync member email to user when email changes (Member → User) # Only runs when email is being changed change Mv.EmailSync.Changes.SyncMemberEmailToUser do where [changing(:email)] end # Sync user email to member when linking (User → Member) # Only runs when user relationship is being changed change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:user)] end end # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true argument :similarity_threshold, :float, allow_nil?: true prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" # Use default similarity threshold if not provided # Lower value leads to more results but also more unspecific results threshold = Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) pat = "%" <> q2 <> "%" # FTS as main filter and fuzzy search just for first name, last name and strees query |> Ash.Query.filter( expr( # Substring on numeric-like fields (best effort, supports middle substrings) fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or contains(postal_code, ^q2) or contains(house_number, ^q2) or contains(phone_number, ^q2) or contains(email, ^q2) or contains(city, ^q2) or ilike(city, ^pat) or fragment("? % first_name", ^q2) or fragment("? % last_name", ^q2) or fragment("? % street", ^q2) or fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or fragment("similarity(street, ?) > ?", ^q2, ^threshold) ) ) else query end end end # Action to find members available for linking to a user account # Returns only unlinked members (user_id == nil), limited to 10 results # # Filtering behavior: # - If search_query provided: fuzzy search on names and email # - If no search_query: return all unlinked members (up to limit) # - user_email should be handled by caller with filter_by_email_match/2 read :available_for_linking do argument :user_email, :string, allow_nil?: true argument :search_query, :string, allow_nil?: true prepare fn query, _ctx -> user_email = Ash.Query.get_argument(query, :user_email) search_query = Ash.Query.get_argument(query, :search_query) query |> Ash.Query.filter(is_nil(user)) |> apply_linking_filters(user_email, search_query) |> Ash.Query.limit(@member_search_limit) end end end @doc """ Filters members list based on email match priority. Priority logic: 1. If email matches a member: return ONLY that member (highest priority) 2. If email doesn't match: return all members (for display in dropdown) This is used with :available_for_linking action to implement email-priority behavior: - user_email matches → Only this member - user_email does NOT match + NO search_query → All unlinked members - user_email does NOT match + search_query provided → search_query filtered members ## Parameters - `members` - List of Member structs (from :available_for_linking action) - `user_email` - Email string to match against member emails ## Returns - List of Member structs (either single email match or all members) ## Examples iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] iex> filter_by_email_match(members, "test@example.com") [%Member{email: "test@example.com"}] iex> filter_by_email_match(members, "nomatch@example.com") [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] """ @spec filter_by_email_match([t()], String.t()) :: [t()] def filter_by_email_match(members, user_email) when is_list(members) and is_binary(user_email) do email_match = Enum.find(members, &(&1.email == user_email)) if email_match do # Email match found - return only this member (highest priority) [email_match] else # No email match - return all members unchanged members end end @spec filter_by_email_match(any(), any()) :: any() def filter_by_email_match(members, _user_email), do: members validations do # Required fields are covered by allow_nil? false # First name and last name must not be empty validate present(:first_name) validate present(:last_name) validate present(:email) # Email uniqueness check for all actions that change the email attribute # Validates that member email is not already used by another (unlinked) user validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser # Prevent linking to a user that already has a member # This validation prevents "stealing" users from other members by checking # if the target user is already linked to a different member # This is necessary because manage_relationship's on_match: :error only checks # if the user is already linked to THIS specific member, not ANY member validate fn changeset, _context -> user_arg = Ash.Changeset.get_argument(changeset, :user) if user_arg && user_arg[:id] do user_id = user_arg[:id] current_member_id = changeset.data.id # Check the current state of the user in the database case Ash.get(Mv.Accounts.User, user_id) do # User is free to be linked {:ok, %{member_id: nil}} -> :ok # User already linked to this member (update scenario) {:ok, %{member_id: ^current_member_id}} -> :ok {:ok, %{member_id: _other_member_id}} -> # User is linked to a different member - prevent "stealing" {:error, field: :user, message: "User is already linked to another member"} {:error, _} -> {:error, field: :user, message: "User not found"} end else :ok end end # 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 # Private helper to apply filters for :available_for_linking action # user_email: may be nil/empty when creating new user, or populated when editing # search_query: optional search term for fuzzy matching # # Logic: (email == user_email) OR (fuzzy_search on search_query) # - Empty user_email ("") → email == "" is always false → only fuzzy search matches # - This allows a single filter expression instead of duplicating fuzzy search logic # # Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires # multiple OR conditions for good search quality (FTS + trigram similarity + substring) # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp apply_linking_filters(query, user_email, search_query) do has_search = search_query && String.trim(search_query) != "" # Use empty string instead of nil to simplify filter logic trimmed_email = if user_email, do: String.trim(user_email), else: "" if has_search do # Search query provided: return email-match OR fuzzy-search candidates trimmed_search = String.trim(search_query) query |> Ash.Query.filter( expr( # Email match candidate (for filter_by_email_match priority) # If email is "", this is always false and fuzzy search takes over # Fuzzy search candidates email == ^trimmed_email or fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or fragment("? % first_name", ^trimmed_search) or fragment("? % last_name", ^trimmed_search) or fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or fragment( "word_similarity(?, last_name) > ?", ^trimmed_search, ^@default_similarity_threshold ) or fragment( "similarity(first_name, ?) > ?", ^trimmed_search, ^@default_similarity_threshold ) or fragment( "similarity(last_name, ?) > ?", ^trimmed_search, ^@default_similarity_threshold ) or contains(email, ^trimmed_search) ) ) else # No search query: return all unlinked (filter_by_email_match will prioritize email if provided) query end end end