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: birth_date and 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 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 [ :first_name, :last_name, :email, :birth_date, :paid, :phone_number, :join_date, :exit_date, :notes, :city, :street, :house_number, :postal_code ] 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 [ :first_name, :last_name, :email, :birth_date, :paid, :phone_number, :join_date, :exit_date, :notes, :city, :street, :house_number, :postal_code ] 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) || "" # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 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 end 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 # Birth date not in the future validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:birth_date)], message: "cannot be in the future" # 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 :birth_date, :date do allow_nil? true 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 # Fuzzy Search function that can be called by live view and calls search action 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 end