From d4c7af558df1b899bb85ef96db354c9f69d8da74 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 5 Aug 2025 17:17:28 +0200 Subject: [PATCH] feat: relate user and member --- lib/accounts/accounts.ex | 11 +++ lib/accounts/user.ex | 13 +++- lib/accounts/user/member_creation_notifier.ex | 43 +++++++++++ lib/membership/member.ex | 2 +- lib/membership/membership.ex | 1 + .../20250724161006_add_unique_member_id.exs | 17 ++++ .../accounts/user_member_integration_test.exs | 77 +++++++++++++++++++ test/membership/member_test.exs | 15 ++-- 8 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 lib/accounts/user/member_creation_notifier.ex create mode 100644 priv/repo/migrations/20250724161006_add_unique_member_id.exs create mode 100644 test/accounts/user_member_integration_test.exs diff --git a/lib/accounts/accounts.ex b/lib/accounts/accounts.ex index 7bc5073..a96b5ac 100644 --- a/lib/accounts/accounts.ex +++ b/lib/accounts/accounts.ex @@ -21,4 +21,15 @@ defmodule Mv.Accounts do resource Mv.Accounts.Token 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 diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index d8c7a66..7163649 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -5,7 +5,8 @@ defmodule Mv.Accounts.User do use Ash.Resource, domain: Mv.Accounts, data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication] + extensions: [AshAuthentication], + notifiers: [Mv.Accounts.User.MemberCreationNotifier] # authorizers: [Ash.Policy.Authorizer] @@ -64,11 +65,16 @@ defmodule Mv.Accounts.User do defaults [:read, :create, :destroy, :update] create :create_user do - accept [:email] + accept [:email, :member_id] + argument :member, :map + change manage_relationship(:member, type: :create) end 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 # Admin action for direct password changes in admin panel @@ -152,6 +158,7 @@ defmodule Mv.Accounts.User do identities do identity :unique_email, [:email] identity :unique_oidc_id, [:oidc_id] + identity :unique_member_id, [:member_id] end # You can customize this if you wish, but this is a safe default that diff --git a/lib/accounts/user/member_creation_notifier.ex b/lib/accounts/user/member_creation_notifier.ex new file mode 100644 index 0000000..27da704 --- /dev/null +++ b/lib/accounts/user/member_creation_notifier.ex @@ -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 \ No newline at end of file diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8284aff..416c928 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -170,7 +170,7 @@ defmodule Mv.Membership.Member do relationships do has_many :properties, Mv.Membership.Property - has_one :user, Mv.Accounts.User + has_one :user, Mv.Accounts.User, destination_attribute: :member_id end calculations do diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 0c7c14d..afed432 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -10,6 +10,7 @@ defmodule Mv.Membership do resource Mv.Membership.Member do define :create_member, action: :create_member define :list_members, action: :read + define :get_member!, action: :read, get_by: [:id] define :update_member, action: :update_member define :destroy_member, action: :destroy end diff --git a/priv/repo/migrations/20250724161006_add_unique_member_id.exs b/priv/repo/migrations/20250724161006_add_unique_member_id.exs new file mode 100644 index 0000000..bc7d7a7 --- /dev/null +++ b/priv/repo/migrations/20250724161006_add_unique_member_id.exs @@ -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 \ No newline at end of file diff --git a/test/accounts/user_member_integration_test.exs b/test/accounts/user_member_integration_test.exs new file mode 100644 index 0000000..e63b1d7 --- /dev/null +++ b/test/accounts/user_member_integration_test.exs @@ -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 \ No newline at end of file diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index 7015d34..8bb1db7 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -8,7 +8,7 @@ defmodule Mv.Membership.MemberTest do last_name: "Doe", birth_date: ~D[1990-01-01], paid: true, - email: "john@example.com", + member_email: "john@example.com", phone_number: "+49123456789", join_date: ~D[2020-01-01], exit_date: nil, @@ -31,16 +31,15 @@ defmodule Mv.Membership.MemberTest do assert error_message(errors, :last_name) =~ "must be present" end - test "Email is required" do - attrs = Map.put(@valid_attrs, :email, "") - assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) - assert error_message(errors, :email) =~ "must be present" + test "Email is optional" do + attrs = Map.delete(@valid_attrs, :member_email) + assert {:ok, _member} = Membership.create_member(attrs) end - test "Email must be valid" do - attrs = Map.put(@valid_attrs, :email, "test@") + test "Email must be valid if provided" do + attrs = Map.put(@valid_attrs, :member_email, "test@") 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 test "Birth date is optional but must not be in the future" do