284 lines
8.4 KiB
Elixir
284 lines
8.4 KiB
Elixir
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
|
|
)
|
|
|
|
# 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
|
|
# 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
|
|
)
|
|
|
|
# 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
|
|
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 :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
|
|
|
|
# Define identities for upsert operations
|
|
identities do
|
|
identity :unique_email, [:email]
|
|
end
|
|
end
|