diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index c65b882..541e29a 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -61,7 +61,7 @@ defmodule Mv.Accounts.User do actions do defaults [:read, :create, :destroy] - + update :update do primary? true require_atomic? false @@ -77,14 +77,10 @@ defmodule Mv.Accounts.User do # 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 + on_lookup: :relate, # Look up existing member and relate to it + on_no_match: :error, # Error if member doesn't exist in database + on_match: :ignore, # If member already linked to this user, ignore (shouldn't happen in create) + on_missing: :ignore # If no member provided, that's fine (optional relationship) ) end @@ -99,15 +95,11 @@ defmodule Mv.Accounts.User do # 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 - ) + on_lookup: :relate, # Look up existing member and relate to it + on_no_match: :error, # Error if member doesn't exist in database + on_match: :ignore, # If same member provided, that's fine (allows updates with same member) + on_missing: :unrelate # If no member provided, remove existing relationship (allows member removal) + ) end # Admin action for direct password changes in admin panel @@ -165,7 +157,7 @@ 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) @@ -174,15 +166,13 @@ defmodule Mv.Accounts.User do 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."} + 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 diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 5641528..1199023 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -39,14 +39,10 @@ defmodule Mv.Membership.Member do # 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 + on_lookup: :relate, # Look up existing user and relate to it + on_no_match: :error, # Error if user doesn't exist in database + on_match: :error, # Error if user is already linked to another member (prevents "stealing") + on_missing: :ignore # If no user provided, that's fine (optional relationship) ) end @@ -80,14 +76,10 @@ defmodule Mv.Membership.Member do # 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 + on_lookup: :relate, # Look up existing user and relate to it + on_no_match: :error, # Error if user doesn't exist in database + on_match: :error, # Error if user is already linked to another member (prevents "stealing") + on_missing: :unrelate # If no user provided, remove existing relationship (allows user removal) ) end end @@ -99,7 +91,7 @@ defmodule Mv.Membership.Member do validate present(:first_name) validate present(:last_name) validate present(:email) - + # 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 @@ -107,25 +99,18 @@ defmodule Mv.Membership.Member do # 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: nil}} -> :ok # User is free to be linked + {:ok, %{member_id: ^current_member_id}} -> :ok # User already linked to this member (update scenario) {: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 diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index b8992cf..304709c 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -37,16 +37,6 @@ defmodule MvWeb.MemberLive.Show do <:item title={gettext("Street")}>{@member.street} <:item title={gettext("House Number")}>{@member.house_number} <:item title={gettext("Postal Code")}>{@member.postal_code} - <:item title={gettext("Linked User")}> - <%= if @member.user do %> - <.link navigate={~p"/users/#{@member.user}"} class="text-blue-600 hover:text-blue-800 underline"> - <.icon name="hero-user" class="h-4 w-4 inline mr-1" /> - {@member.user.email} - - <% else %> - {gettext("No user linked")} - <% end %> -

{gettext("Custom Properties")}

@@ -77,7 +67,7 @@ defmodule MvWeb.MemberLive.Show do query = Mv.Membership.Member |> filter(id == ^id) - |> load([:user, properties: [:property_type]]) + |> load(properties: [:property_type]) member = Ash.read_one!(query) diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 1001b94..3bf6baf 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -26,16 +26,6 @@ defmodule MvWeb.UserLive.Show do <:item title={gettext("Password Authentication")}> {if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")} - <:item title={gettext("Linked Member")}> - <%= if @user.member do %> - <.link navigate={~p"/members/#{@user.member}"} class="text-blue-600 hover:text-blue-800 underline"> - <.icon name="hero-users" class="h-4 w-4 inline mr-1" /> - {@user.member.first_name} {@user.member.last_name} - - <% else %> - {gettext("No member linked")} - <% end %> - """ @@ -43,11 +33,9 @@ defmodule MvWeb.UserLive.Show do @impl true def mount(%{"id" => id}, _session, socket) do - user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) - {:ok, socket |> assign(:page_title, gettext("Show User")) - |> assign(:user, user)} + |> assign(:user, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))} end end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 3ff747e..cb38969 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -92,86 +92,3 @@ for member_attrs <- [ ] do Membership.create_member!(member_attrs) end - -# Create additional users for user-member linking examples -additional_users = [ - %{email: "hans.mueller@example.de"}, - %{email: "greta.schmidt@example.de"}, - %{email: "maria.weber@example.de"}, - %{email: "thomas.klein@example.de"} -] - -created_users = - Enum.map(additional_users, fn user_attrs -> - Accounts.create_user!(user_attrs, upsert?: true, upsert_identity: :unique_email) - |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"}) - |> Ash.update!() - end) - -# Create members with linked users to demonstrate the 1:1 relationship -# Only create if users don't already have members -linked_members = [ - %{ - first_name: "Maria", - last_name: "Weber", - email: "maria.weber@example.de", - birth_date: ~D[1992-07-14], - join_date: ~D[2023-03-15], - paid: true, - phone_number: "+49301357924", - city: "Frankfurt", - street: "Goetheplatz", - house_number: "5", - postal_code: "60313", - notes: "Linked to user account", - # Link to the third user (maria.weber@example.de) - user: Enum.at(created_users, 2) - }, - %{ - first_name: "Thomas", - last_name: "Klein", - email: "thomas.klein@example.de", - birth_date: ~D[1988-12-03], - join_date: ~D[2023-04-01], - paid: false, - phone_number: "+49302468135", - city: "Köln", - street: "Rheinstraße", - house_number: "23", - postal_code: "50667", - notes: "Linked to user account - needs payment follow-up", - # Link to the fourth user (thomas.klein@example.de) - user: Enum.at(created_users, 3) - } -] - -# Create the linked members only if the users don't already have members -Enum.each(linked_members, fn member_attrs -> - user = member_attrs.user - member_attrs_without_user = Map.delete(member_attrs, :user) - - # Check if user already has a member - if user.member_id == nil do - # User is free, create member and link - Membership.create_member!(Map.put(member_attrs_without_user, :user, %{id: user.id})) - else - # User already has a member, just create the member without linking - Membership.create_member!(member_attrs_without_user) - end -end) - -IO.puts("✅ Seeds completed successfully!") -IO.puts("📝 Created sample data:") -IO.puts(" - Property types: String, Date, Boolean, Email") -IO.puts(" - Admin user: admin@mv.local (password: testpassword)") -IO.puts(" - Sample members: Hans, Greta, Friedrich") - -IO.puts( - " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" -) - -IO.puts( - " - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de" -) - -IO.puts("🔗 Visit the application to see user-member relationships in action!") diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs index 19cbe62..f85a704 100644 --- a/test/accounts/user_member_relationship_test.exs +++ b/test/accounts/user_member_relationship_test.exs @@ -148,8 +148,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do email: "dave@example.com" }) - assert {:error, %Ash.Error.Invalid{}} = - Accounts.update_user(user, %{member: %{id: member2.id}}) + assert {:error, %Ash.Error.Invalid{}} = Accounts.update_user(user, %{member: %{id: member2.id}}) end test "prevents linking user to already linked member on update" do @@ -160,8 +159,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do {:ok, user2} = Accounts.create_user(%{email: "test5@example.com"}) - assert {:error, %Ash.Error.Invalid{}} = - Accounts.update_user(user2, %{member: %{id: member.id}}) + assert {:error, %Ash.Error.Invalid{}} = Accounts.update_user(user2, %{member: %{id: member.id}}) end test "prevents linking member to already linked user on creation" do @@ -186,10 +184,10 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}}) assert {:error, %Ash.Error.Invalid{}} = - Accounts.create_user(%{ - email: "test5@example.com", - member: %{id: member.id} - }) + Accounts.create_user(%{ + email: "test5@example.com", + member: %{id: member.id} + }) end end end