diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 541e29a..c65b882 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,10 +77,14 @@ defmodule Mv.Accounts.User do # Manage the member relationship during user creation change manage_relationship(:member, :member, - 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) + # 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 @@ -95,11 +99,15 @@ defmodule Mv.Accounts.User do # Manage the member relationship during user update change manage_relationship(:member, :member, - 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) - ) + # 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 @@ -157,7 +165,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) @@ -166,13 +174,15 @@ 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 1199023..5641528 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -39,10 +39,14 @@ defmodule Mv.Membership.Member do # Manage the user relationship during member creation change manage_relationship(:user, :user, - 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) + # 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 ) end @@ -76,10 +80,14 @@ defmodule Mv.Membership.Member do # Manage the user relationship during member update change manage_relationship(:user, :user, - 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) + # 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 ) end end @@ -91,7 +99,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 @@ -99,18 +107,25 @@ 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 - {: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) + # 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: _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 304709c..b8992cf 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -37,6 +37,16 @@ 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")}

@@ -67,7 +77,7 @@ defmodule MvWeb.MemberLive.Show do query = Mv.Membership.Member |> filter(id == ^id) - |> load(properties: [:property_type]) + |> load([:user, 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 3bf6baf..1001b94 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -26,6 +26,16 @@ 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 %> + """ @@ -33,9 +43,11 @@ 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, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))} + |> assign(:user, user)} end end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index cb38969..3ff747e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -92,3 +92,86 @@ 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 f85a704..19cbe62 100644 --- a/test/accounts/user_member_relationship_test.exs +++ b/test/accounts/user_member_relationship_test.exs @@ -148,7 +148,8 @@ 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 @@ -159,7 +160,8 @@ 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 @@ -184,10 +186,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