feat: member user relation

This commit is contained in:
Moritz 2025-09-26 16:09:49 +02:00
parent 5aa9c37742
commit 72a8415cb3
Signed by: moritz
GPG key ID: 1020A035E5DD0824
4 changed files with 296 additions and 1 deletions

View file

@ -60,15 +60,54 @@ defmodule Mv.Accounts.User do
end
actions do
defaults [:read, :create, :destroy, :update]
defaults [:read, :create, :destroy]
update :update do
primary? true
require_atomic? false
end
create :create_user do
# Only accept email directly - member_id is NOT in accept list
# This prevents direct foreign key manipulation, forcing use of manage_relationship
accept [:email]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
upsert? true
# Manage the member relationship during user creation
change manage_relationship(:member, :member,
# Look up existing member and relate to it
on_lookup: :relate,
# Error if member doesn't exist in database
on_no_match: :error,
# If member already linked to this user, ignore (shouldn't happen in create)
on_match: :ignore,
# If no member provided, that's fine (optional relationship)
on_missing: :ignore
)
end
update :update_user do
# Only accept email directly - member_id is NOT in accept list
# This prevents direct foreign key manipulation, forcing use of manage_relationship
accept [:email]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
# Required because custom validation function cannot be done atomically
require_atomic? false
# Manage the member relationship during user update
change manage_relationship(:member, :member,
# Look up existing member and relate to it
on_lookup: :relate,
# Error if member doesn't exist in database
on_no_match: :error,
# If same member provided, that's fine (allows updates with same member)
on_match: :ignore,
# If no member provided, remove existing relationship (allows member removal)
on_missing: :unrelate
)
end
# Admin action for direct password changes in admin panel
@ -76,6 +115,7 @@ defmodule Mv.Accounts.User do
update :admin_set_password do
accept [:email]
argument :password, :string, allow_nil?: false, sensitive?: true
require_atomic? false
# Set the strategy context that HashPasswordChange expects
change set_context(%{strategy_name: :password})
@ -125,6 +165,28 @@ defmodule Mv.Accounts.User do
validate string_length(:password, min: 8) do
where action_is([:register_with_password, :admin_set_password])
end
# Prevent overwriting existing member relationship
# This validation ensures race condition safety by requiring explicit two-step process:
# 1. Remove existing member (set member to nil)
# 2. Add new member
# This prevents accidental overwrites when multiple admins work simultaneously
validate fn changeset, _context ->
member_arg = Ash.Changeset.get_argument(changeset, :member)
current_member_id = changeset.data.member_id
# Only trigger if:
# - member argument is provided AND has an ID
# - user currently has a member
# - the new member ID is different from current member ID
if member_arg && member_arg[:id] && current_member_id &&
member_arg[:id] != current_member_id do
{:error,
field: :member, message: "User already has a member. Remove existing member first."}
else
:ok
end
end
end
def validate_oidc_id_present(changeset, _context) do
@ -146,12 +208,16 @@ defmodule Mv.Accounts.User do
end
relationships do
# 1:1 relationship - User can optionally belong to one Member
# This automatically creates a `member_id` attribute in the User table
# The relationship is optional (allow_nil? true by default)
belongs_to :member, Mv.Membership.Member
end
identities do
identity :unique_email, [:email]
identity :unique_oidc_id, [:oidc_id]
identity :unique_member, [:member_id]
end
# You can customize this if you wish, but this is a safe default that