All checks were successful
continuous-integration/drone/push Build is passing
Complete refactoring of resources, database tables, code references, tests, and documentation for improved naming consistency.
380 lines
12 KiB
Elixir
380 lines
12 KiB
Elixir
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
|