WIP: feature/119_member_user_relation_refactor closes #119 #145

Closed
moritz wants to merge 6 commits from feature/119_member_user_relation_refactor into main
8 changed files with 167 additions and 12 deletions
Showing only changes of commit d4c7af558d - Show all commits

View file

@ -21,4 +21,15 @@ defmodule Mv.Accounts do
resource Mv.Accounts.Token resource Mv.Accounts.Token
end end
@doc """
Register a new user with password using AshAuthentication's standard action.
This creates a user and the notifier will automatically create a member.
"""
def register_with_password(params) do
# Use AshAuthentication's standard register_with_password action
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, params)
|> Ash.create(domain: __MODULE__)
end
end end

View file

@ -5,7 +5,8 @@ defmodule Mv.Accounts.User do
use Ash.Resource, use Ash.Resource,
domain: Mv.Accounts, domain: Mv.Accounts,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication] extensions: [AshAuthentication],
notifiers: [Mv.Accounts.User.MemberCreationNotifier]
# authorizers: [Ash.Policy.Authorizer] # authorizers: [Ash.Policy.Authorizer]
@ -64,11 +65,16 @@ defmodule Mv.Accounts.User do
defaults [:read, :create, :destroy, :update] defaults [:read, :create, :destroy, :update]
create :create_user do create :create_user do
accept [:email] accept [:email, :member_id]
argument :member, :map
change manage_relationship(:member, type: :create)
end end
update :update_user do update :update_user do
accept [:email] require_atomic? false
accept [:email, :member_id]
argument :member, :map
change manage_relationship(:member, on_match: :update, on_no_match: :create)
end end
# Admin action for direct password changes in admin panel # Admin action for direct password changes in admin panel
@ -152,6 +158,7 @@ defmodule Mv.Accounts.User do
identities do identities do
identity :unique_email, [:email] identity :unique_email, [:email]
identity :unique_oidc_id, [:oidc_id] identity :unique_oidc_id, [:oidc_id]
identity :unique_member_id, [:member_id]
end end
# You can customize this if you wish, but this is a safe default that # You can customize this if you wish, but this is a safe default that

View file

@ -0,0 +1,43 @@
defmodule Mv.Accounts.User.MemberCreationNotifier do
@moduledoc """
Notifier that automatically creates a member for users who don't have one.
This ensures that every user has an associated member profile.
"""
use Ash.Notifier
def notify(%Ash.Notifier.Notification{
action: %{name: action_name},
resource: Mv.Accounts.User,
data: user
})
when action_name in [:create_user, :register_with_password] do
# Only create member if user doesn't have one
if is_nil(user.member_id) do
create_member_for_user(user)
else
:ok
end
end
def notify(_), do: :ok
defp create_member_for_user(user) do
member_attrs = %{
first_name: "User",
last_name: "Generated",
member_email: user.email
}
case Mv.Membership.create_member(member_attrs) do
{:ok, member} ->
# Update the user with the new member_id
Mv.Accounts.update_user(user, %{member_id: member.id})
{:error, _error} ->
# Log error but don't fail the user creation
# In a real application, you might want to handle this differently
:ok
end
end
end

View file

@ -170,7 +170,7 @@ defmodule Mv.Membership.Member do
relationships do relationships do
has_many :properties, Mv.Membership.Property has_many :properties, Mv.Membership.Property
has_one :user, Mv.Accounts.User has_one :user, Mv.Accounts.User, destination_attribute: :member_id
end end
calculations do calculations do

View file

@ -10,6 +10,7 @@ defmodule Mv.Membership do
resource Mv.Membership.Member do resource Mv.Membership.Member do
define :create_member, action: :create_member define :create_member, action: :create_member
define :list_members, action: :read define :list_members, action: :read
define :get_member!, action: :read, get_by: [:id]
define :update_member, action: :update_member define :update_member, action: :update_member
define :destroy_member, action: :destroy define :destroy_member, action: :destroy
end end

View file

@ -0,0 +1,17 @@
defmodule Mv.Repo.Migrations.AddUniqueMemberId do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create unique_index(:users, [:member_id], name: "users_unique_member_id_index")
end
def down do
drop_if_exists unique_index(:users, [:member_id], name: "users_unique_member_id_index")
end
end

View file

@ -0,0 +1,77 @@
defmodule Mv.Accounts.UserMemberIntegrationTest do
use Mv.DataCase, async: true
alias Mv.Accounts
alias Mv.Membership
alias Mv.Accounts.User.MemberCreationNotifier
describe "User-Member-Relation" do
test "ein User kann einem Member zugeordnet werden" do
{:ok, member} =
Membership.create_member(%{
first_name: "Max",
last_name: "Mustermann",
member_email: "max@example.com"
})
{:ok, user} = Accounts.create_user(%{email: "user1@example.com", member_id: member.id})
assert user.member_id == member.id
end
test "ein Member kann nur einem User zugeordnet werden (unique constraint)" do
{:ok, member} =
Membership.create_member(%{
first_name: "Anna",
last_name: "Test",
member_email: "anna@example.com"
})
{:ok, user1} = Accounts.create_user(%{email: "user2@example.com", member_id: member.id})
assert user1.member_id == member.id
{:error, %Ash.Error.Invalid{errors: errors}} =
Accounts.create_user(%{email: "user3@example.com", member_id: member.id})
assert Enum.any?(errors, fn error ->
error.message =~ "already been taken" or error.field == :member_id
end)
end
test "ein User ohne Member ist nicht erlaubt (bei Registrierung/Erstellung)" do
# Create user without member first
result = Accounts.create_user(%{email: "user4@example.com"})
case result do
{:ok, user} ->
# User is created but doesn't have member yet
assert user.member_id == nil
# Manually trigger the notifier to simulate automatic member creation
notification = %Ash.Notifier.Notification{
action: %{name: :create_user},
resource: Mv.Accounts.User,
data: user
}
{:ok, _updated_user} = MemberCreationNotifier.notify(notification)
# Reload user and verify member was created and assigned
user = Ash.reload!(user, domain: Mv.Accounts)
assert user.member_id, "User should have a member_id assigned after notifier"
{:error, _} ->
flunk("User creation should succeed")
end
end
test "ein Member kann ohne User existieren" do
{:ok, member} =
Membership.create_member(%{
first_name: "Lisa",
last_name: "Solo",
member_email: "lisa@example.com"
})
assert member.id
end
end
end

View file

@ -8,7 +8,7 @@ defmodule Mv.Membership.MemberTest do
last_name: "Doe", last_name: "Doe",
birth_date: ~D[1990-01-01], birth_date: ~D[1990-01-01],
paid: true, paid: true,
email: "john@example.com", member_email: "john@example.com",
phone_number: "+49123456789", phone_number: "+49123456789",
join_date: ~D[2020-01-01], join_date: ~D[2020-01-01],
exit_date: nil, exit_date: nil,
@ -31,16 +31,15 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :last_name) =~ "must be present" assert error_message(errors, :last_name) =~ "must be present"
end end
test "Email is required" do test "Email is optional" do
attrs = Map.put(@valid_attrs, :email, "") attrs = Map.delete(@valid_attrs, :member_email)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) assert {:ok, _member} = Membership.create_member(attrs)
assert error_message(errors, :email) =~ "must be present"
end end
test "Email must be valid" do test "Email must be valid if provided" do
attrs = Map.put(@valid_attrs, :email, "test@") attrs = Map.put(@valid_attrs, :member_email, "test@")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :email) =~ "is not a valid email" assert error_message(errors, :member_email) =~ "is not a valid email"
end end
test "Birth date is optional but must not be in the future" do test "Birth date is optional but must not be in the future" do