WIP: feature/119_member_user_relation_refactor closes #119 #145
8 changed files with 167 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
43
lib/accounts/user/member_creation_notifier.ex
Normal file
43
lib/accounts/user/member_creation_notifier.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
17
priv/repo/migrations/20250724161006_add_unique_member_id.exs
Normal file
17
priv/repo/migrations/20250724161006_add_unique_member_id.exs
Normal 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
|
||||||
77
test/accounts/user_member_integration_test.exs
Normal file
77
test/accounts/user_member_integration_test.exs
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue