defmodule Mv.Membership.Member do use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer postgres do table "members" repo Mv.Repo end actions do defaults [:read, :destroy] create :create_member do primary? true # Properties can be created along with member argument :properties, {: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(:properties, 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 ) end update :update_member do primary? true # Required because custom validation function cannot be done atomically require_atomic? false # Properties can be updated or created along with member argument :properties, {: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(:properties, 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 ) 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) # 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 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 :properties, Mv.Membership.Property # 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 end