From 91c5e17994589f475fd5bdfff3f2c081f3f79580 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 16:57:34 +0200 Subject: [PATCH 1/9] email sync tests --- test/accounts/email_sync_edge_cases_test.exs | 93 ++++++++++ test/accounts/user_email_sync_test.exs | 169 +++++++++++++++++++ test/membership/member_email_sync_test.exs | 127 ++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 test/accounts/email_sync_edge_cases_test.exs create mode 100644 test/accounts/user_email_sync_test.exs create mode 100644 test/membership/member_email_sync_test.exs diff --git a/test/accounts/email_sync_edge_cases_test.exs b/test/accounts/email_sync_edge_cases_test.exs new file mode 100644 index 0000000..b872235 --- /dev/null +++ b/test/accounts/email_sync_edge_cases_test.exs @@ -0,0 +1,93 @@ +defmodule Mv.Accounts.EmailSyncEdgeCasesTest do + @moduledoc """ + Edge case tests for email synchronization between User and Member. + Tests various boundary conditions and validation scenarios. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "Email sync edge cases" do + @valid_user_attrs %{ + email: "user@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + } + + test "simultaneous email updates use user email as source of truth" do + # Create linked user and member + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + # Verify link and initial sync + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + + # Scenario: Both emails are updated "simultaneously" + # In practice, this tests that when a member email is updated, + # it syncs to user, and user remains the source of truth + + # Update member email first + {:ok, _updated_member} = + Membership.update_member(member, %{email: "member-new@example.com"}) + + # Verify it synced to user + {:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id) + assert to_string(user_after_member_update.email) == "member-new@example.com" + + # Now update user email - this should override + {:ok, _updated_user} = + Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"}) + + # Reload both + {:ok, final_user} = Ash.get(Mv.Accounts.User, user.id) + {:ok, final_member} = Ash.get(Mv.Membership.Member, member.id) + + # User email should be the final truth + assert to_string(final_user.email) == "user-final@example.com" + assert final_member.email == "user-final@example.com" + end + + test "email validation works for both user and member" do + # Test that invalid emails are rejected for both resources + + # Invalid email for user + invalid_user_result = Accounts.create_user(%{email: "not-an-email"}) + assert {:error, %Ash.Error.Invalid{}} = invalid_user_result + + # Invalid email for member + invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email") + invalid_member_result = Membership.create_member(invalid_member_attrs) + assert {:error, %Ash.Error.Invalid{}} = invalid_member_result + + # Valid emails should work + {:ok, _user} = Accounts.create_user(@valid_user_attrs) + {:ok, _member} = Membership.create_member(@valid_member_attrs) + end + + test "identity constraints prevent duplicate emails" do + # Create first user with an email + {:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"}) + assert to_string(user1.email) == "duplicate@example.com" + + # Try to create second user with same email - should fail due to unique constraint + result = Accounts.create_user(%{email: "duplicate@example.com"}) + assert {:error, %Ash.Error.Invalid{}} = result + + # Same for members + member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com") + {:ok, member1} = Membership.create_member(member_attrs) + assert member1.email == "member-dup@example.com" + + # Try to create second member with same email - should fail + result2 = Membership.create_member(member_attrs) + assert {:error, %Ash.Error.Invalid{}} = result2 + end + end +end diff --git a/test/accounts/user_email_sync_test.exs b/test/accounts/user_email_sync_test.exs new file mode 100644 index 0000000..6d08d61 --- /dev/null +++ b/test/accounts/user_email_sync_test.exs @@ -0,0 +1,169 @@ +defmodule Mv.Accounts.UserEmailSyncTest do + @moduledoc """ + Tests for email synchronization from User to Member. + When a user and member are linked, email changes should sync bidirectionally. + User.email is the source of truth when linking occurs. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "User email synchronization to linked Member" do + @valid_user_attrs %{ + email: "user@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + } + + test "updating user email syncs to linked member" do + # Create a member + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Create a user linked to the member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + # Verify initial state - member email should be overridden by user email + {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_link.email == "user@example.com" + + # Update user email + {:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"}) + assert to_string(updated_user.email) == "newemail@example.com" + + # Verify member email was also updated + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "newemail@example.com" + end + + test "creating user linked to member overrides member email" do + # Create a member with their own email + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Create a user linked to this member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + assert to_string(user.email) == "user@example.com" + assert user.member_id == member.id + + # Verify member email was overridden with user email + {:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id) + assert updated_member.email == "user@example.com" + end + + test "linking user to existing member syncs user email to member" do + # Create a standalone member + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Create a standalone user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + assert user.member_id == nil + + # Link the user to the member + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + assert linked_user.member_id == member.id + + # Verify member email was overridden with user email + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + end + + test "updating user email when no member linked does not error" do + # Create a standalone user without member link + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + assert user.member_id == nil + + # Update user email - should work fine without error + {:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"}) + assert to_string(updated_user.email) == "newemail@example.com" + assert updated_user.member_id == nil + end + + test "unlinking user from member does not sync email" do + # Create member + {:ok, member} = Membership.create_member(@valid_member_attrs) + + # Create user linked to member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + assert user.member_id == member.id + + # Verify member email was synced + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + + # Unlink user from member + {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}) + assert unlinked_user.member_id == nil + + # Member email should remain unchanged after unlinking + {:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_unlink.email == "user@example.com" + end + end + + describe "AshAuthentication compatibility" do + test "AshAuthentication password strategy still works with email" do + # This test ensures that the email field remains accessible for password auth + email = "test@example.com" + password = "securepassword123" + + # Create user with password strategy (simulating registration) + {:ok, user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:register_with_password, %{ + email: email, + password: password + }) + |> Ash.create() + + assert to_string(user.email) == email + assert user.hashed_password != nil + + # Verify we can sign in with email + {:ok, signed_in_user} = + Mv.Accounts.User + |> Ash.Query.for_read(:sign_in_with_password, %{ + email: email, + password: password + }) + |> Ash.read_one() + + assert signed_in_user.id == user.id + assert to_string(signed_in_user.email) == email + end + + test "AshAuthentication OIDC strategy still works with email" do + # This test ensures the OIDC flow can still set email + user_info = %{ + "preferred_username" => "oidc@example.com", + "sub" => "oidc-user-123" + } + + oauth_tokens = %{"access_token" => "mock_token"} + + # Simulate OIDC registration + {:ok, user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:register_with_rauthy, %{ + user_info: user_info, + oauth_tokens: oauth_tokens + }) + |> Ash.create() + + assert to_string(user.email) == "oidc@example.com" + assert user.oidc_id == "oidc-user-123" + end + end +end diff --git a/test/membership/member_email_sync_test.exs b/test/membership/member_email_sync_test.exs new file mode 100644 index 0000000..eeef210 --- /dev/null +++ b/test/membership/member_email_sync_test.exs @@ -0,0 +1,127 @@ +defmodule Mv.Membership.MemberEmailSyncTest do + @moduledoc """ + Tests for email synchronization from Member to User. + When a member and user are linked, email changes should sync bidirectionally. + User.email is the source of truth when linking occurs. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "Member email synchronization to linked User" do + @valid_user_attrs %{ + email: "user@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + } + + test "updating member email syncs to linked user" do + # Create a user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + + # Create a member linked to the user + {:ok, member} = + Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id})) + + # Verify initial state - member email should be overridden by user email + {:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_create.email == "user@example.com" + + # Update member email + {:ok, updated_member} = + Membership.update_member(member, %{email: "newmember@example.com"}) + + assert updated_member.email == "newmember@example.com" + + # Verify user email was also updated + {:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id) + assert to_string(synced_user.email) == "newmember@example.com" + end + + test "creating member linked to user syncs user email to member" do + # Create a user with their own email + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + + # Create a member linked to this user + {:ok, member} = + Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id})) + + # Member should have been created with user's email (user is source of truth) + assert member.email == "user@example.com" + + # Verify the link + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert loaded_member.user.id == user.id + end + + test "linking member to existing user syncs user email to member" do + # Create a standalone user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert to_string(user.email) == "user@example.com" + + # Create a standalone member + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Link the member to the user + {:ok, linked_member} = Membership.update_member(member, %{user: %{id: user.id}}) + + # Verify the link + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, linked_member.id, load: [:user]) + assert loaded_member.user.id == user.id + + # Verify member email was overridden with user email + assert loaded_member.email == "user@example.com" + end + + test "updating member email when no user linked does not error" do + # Create a standalone member without user link + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.email == "member@example.com" + + # Load to verify no user link + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert loaded_member.user == nil + + # Update member email - should work fine without error + {:ok, updated_member} = + Membership.update_member(member, %{email: "newemail@example.com"}) + + assert updated_member.email == "newemail@example.com" + end + + test "unlinking member from user does not sync email" do + # Create user + {:ok, user} = Accounts.create_user(@valid_user_attrs) + + # Create member linked to user + {:ok, member} = + Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id})) + + # Verify member email was synced to user email + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + + # Verify link exists + {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert loaded_member.user != nil + + # Unlink member from user + {:ok, unlinked_member} = Membership.update_member(member, %{user: nil}) + + # Verify unlink + {:ok, loaded_unlinked} = Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user]) + assert loaded_unlinked.user == nil + + # User email should remain unchanged after unlinking + {:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id) + assert to_string(user_after_unlink.email) == "user@example.com" + end + end +end -- 2.47.2 From 5a0a261cd602a6d72b877c6e82c017936285e349 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 17:51:31 +0200 Subject: [PATCH 2/9] add action changes for email sync --- lib/accounts/user.ex | 22 +++++ lib/membership/member.ex | 16 ++++ .../changes/override_member_email_on_link.ex | 47 ++++++++++ .../user/changes/sync_email_to_member.ex | 55 +++++++++++ lib/mv/email_sync/helpers.ex | 93 +++++++++++++++++++ .../override_email_from_user_on_link.ex | 45 +++++++++ .../member/changes/sync_email_to_user.ex | 53 +++++++++++ 7 files changed, 331 insertions(+) create mode 100644 lib/mv/accounts/user/changes/override_member_email_on_link.ex create mode 100644 lib/mv/accounts/user/changes/sync_email_to_member.ex create mode 100644 lib/mv/email_sync/helpers.ex create mode 100644 lib/mv/membership/member/changes/override_email_from_user_on_link.ex create mode 100644 lib/mv/membership/member/changes/sync_email_to_user.ex diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 668ddd4..7101e16 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -89,6 +89,9 @@ defmodule Mv.Accounts.User do # cannot be executed atomically. These validations need to query the database and perform # complex checks that are not supported in atomic operations. require_atomic? false + + # Sync email changes to linked member + change Mv.Accounts.User.Changes.SyncEmailToMember end create :create_user do @@ -111,6 +114,9 @@ defmodule Mv.Accounts.User do # If no member provided, that's fine (optional relationship) on_missing: :ignore ) + + # Override member email with user email when linking + change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink end update :update_user do @@ -137,6 +143,11 @@ defmodule Mv.Accounts.User do # If no member provided, remove existing relationship (allows member removal) on_missing: :unrelate ) + + # Sync email changes to linked member + change Mv.Accounts.User.Changes.SyncEmailToMember + # Override member email with user email when linking + change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink end # Admin action for direct password changes in admin panel @@ -185,6 +196,9 @@ defmodule Mv.Accounts.User do |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end + + # Override member email with user email when linking (if member relationship exists) + change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink end end @@ -255,6 +269,14 @@ defmodule Mv.Accounts.User do attributes do uuid_primary_key :id + # IMPORTANT: Email Synchronization + # When user and member are linked, emails are automatically synced bidirectionally. + # User.email is the source of truth - when a link is established, member.email + # is overridden to match user.email. Subsequent changes to either email will + # sync to the other resource. + # See: Mv.Accounts.User.Changes.SyncEmailToMember + # Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Mv.Membership.Member.Changes.SyncEmailToUser attribute :email, :ci_string do allow_nil? false public? true diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 4cec072..9330922 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -48,6 +48,9 @@ defmodule Mv.Membership.Member do # If no user provided, that's fine (optional relationship) on_missing: :ignore ) + + # Override member email with user email when linking + change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink end update :update_member do @@ -89,6 +92,11 @@ defmodule Mv.Membership.Member do # If no user provided, remove existing relationship (allows user removal) on_missing: :unrelate ) + + # Override member email with user email when linking + change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink + # Sync email changes to linked user + change Mv.Membership.Member.Changes.SyncEmailToUser end end @@ -189,6 +197,14 @@ defmodule Mv.Membership.Member do constraints min_length: 1 end + # IMPORTANT: Email Synchronization + # When member and user are linked, emails are automatically synced bidirectionally. + # User.email is the source of truth - when a link is established, member.email + # is overridden to match user.email. Subsequent changes to either email will + # sync to the other resource. + # See: Mv.Membership.Member.Changes.SyncEmailToUser + # Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink + # Mv.Accounts.User.Changes.SyncEmailToMember attribute :email, :string do allow_nil? false constraints min_length: 5, max_length: 254 diff --git a/lib/mv/accounts/user/changes/override_member_email_on_link.ex b/lib/mv/accounts/user/changes/override_member_email_on_link.ex new file mode 100644 index 0000000..7361718 --- /dev/null +++ b/lib/mv/accounts/user/changes/override_member_email_on_link.ex @@ -0,0 +1,47 @@ +defmodule Mv.Accounts.User.Changes.OverrideMemberEmailOnLink do + @moduledoc """ + Overrides member email with user email when linking a user to a member. + + When a user is linked to a member (either during creation or update), + this change ensures that the member's email is updated to match the user's email. + + User.email is the source of truth when a link is established. + + Uses `around_transaction` to guarantee atomicity - both the user + creation/update and member email override happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + # Skip if already syncing to avoid recursion + if Map.get(context, :syncing_email, false) do + changeset + else + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, user.email) + else + _ -> result + end + end) + end + end + + # Pattern match on nil member_id - no member linked + defp get_linked_member(%{member_id: nil}), do: nil + + # Load linked member by ID + defp get_linked_member(%{member_id: id}) do + case Ash.get(Mv.Membership.Member, id) do + {:ok, member} -> member + {:error, _} -> nil + end + end +end diff --git a/lib/mv/accounts/user/changes/sync_email_to_member.ex b/lib/mv/accounts/user/changes/sync_email_to_member.ex new file mode 100644 index 0000000..553ca91 --- /dev/null +++ b/lib/mv/accounts/user/changes/sync_email_to_member.ex @@ -0,0 +1,55 @@ +defmodule Mv.Accounts.User.Changes.SyncEmailToMember do + @moduledoc """ + Synchronizes user email changes to the linked member. + + When a user's email is updated and the user is linked to a member, + this change automatically updates the member's email to match. + + This ensures bidirectional email synchronization with User.email + as the source of truth. + + Uses `around_transaction` to guarantee atomicity - both the user + and member updates happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + cond do + # Skip if already syncing to avoid recursion + Map.get(context, :syncing_email, false) -> + changeset + + # Only proceed if email is actually changing + not Ash.Changeset.changing_attribute?(changeset, :email) -> + changeset + + # Apply the sync logic + true -> + new_email = Ash.Changeset.get_attribute(changeset, :email) + + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, new_email) + else + _ -> result + end + end) + end + end + + defp get_linked_member(%{member_id: nil}), do: nil + + defp get_linked_member(%{member_id: member_id}) do + case Ash.get(Mv.Membership.Member, member_id) do + {:ok, member} -> member + {:error, _} -> nil + end + end +end diff --git a/lib/mv/email_sync/helpers.ex b/lib/mv/email_sync/helpers.ex new file mode 100644 index 0000000..7feaf57 --- /dev/null +++ b/lib/mv/email_sync/helpers.ex @@ -0,0 +1,93 @@ +defmodule Mv.EmailSync.Helpers do + @moduledoc """ + Shared helper functions for email synchronization between User and Member. + + Handles the complexity of `around_transaction` callback results and + provides clean abstractions for email updates within transactions. + """ + + require Logger + import Ecto.Changeset + + @doc """ + Extracts the record from an Ash action result. + + Handles both 2-tuple `{:ok, record}` and 4-tuple + `{:ok, record, changeset, notifications}` patterns. + """ + def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record} + def extract_record({:ok, record}), do: {:ok, record} + def extract_record({:error, _} = error), do: error + + @doc """ + Updates the result with a new record while preserving the original structure. + + If the original result was a 4-tuple, returns a 4-tuple with the updated record. + If it was a 2-tuple, returns a 2-tuple with the updated record. + """ + def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do + {:ok, new_record, changeset, notifications} + end + + def update_result_record({:ok, _old_record}, new_record) do + {:ok, new_record} + end + + @doc """ + Updates an email field directly via Ecto within the current transaction. + + This bypasses Ash's action system to ensure the update happens in the + same database transaction as the parent action. + """ + def update_email_via_ecto(record, new_email) do + record + |> cast(%{email: to_string(new_email)}, [:email]) + |> Mv.Repo.update() + end + + @doc """ + Synchronizes email to a linked record if it exists. + + Returns the original result unchanged, or an error if sync fails. + """ + def sync_email_to_linked_record(result, linked_record, new_email) do + with {:ok, _source} <- extract_record(result), + record when not is_nil(record) <- linked_record, + {:ok, _updated} <- update_email_via_ecto(record, new_email) do + # Successfully synced - return original result unchanged + result + else + nil -> + # No linked record - return original result + result + + {:error, error} -> + # Sync failed - log and propagate error to rollback transaction + Logger.error("Email sync failed: #{inspect(error)}") + {:error, error} + end + end + + @doc """ + Overrides the record's email with the linked email if emails differ. + + Returns updated result with new record, or original result if no update needed. + """ + def override_with_linked_email(result, linked_email) do + with {:ok, record} <- extract_record(result), + true <- record.email != to_string(linked_email), + {:ok, updated_record} <- update_email_via_ecto(record, linked_email) do + # Email was different - return result with updated record + update_result_record(result, updated_record) + else + false -> + # Emails already match - no update needed + result + + {:error, error} -> + # Override failed - log and propagate error + Logger.error("Email override failed: #{inspect(error)}") + {:error, error} + end + end +end diff --git a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex new file mode 100644 index 0000000..b55a696 --- /dev/null +++ b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex @@ -0,0 +1,45 @@ +defmodule Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink do + @moduledoc """ + Overrides member email with user email when linking a member to a user. + + When a member is linked to a user (either during creation or update), + this change ensures that the member's email is updated to match the user's email. + + User.email is the source of truth when a link is established. + + Uses `around_transaction` to guarantee atomicity - both the member + creation/update and email override happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + # Skip if already syncing to avoid recursion + if Map.get(context, :syncing_email, false) do + changeset + else + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + {:ok, user} <- load_linked_user(member) do + Helpers.override_with_linked_email(result, user.email) + else + _ -> result + end + end) + end + end + + # Load the linked user, returning error tuple if not linked + defp load_linked_user(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} when not is_nil(user) -> {:ok, user} + {:ok, _} -> {:error, :no_linked_user} + {:error, _} = error -> error + end + end +end diff --git a/lib/mv/membership/member/changes/sync_email_to_user.ex b/lib/mv/membership/member/changes/sync_email_to_user.ex new file mode 100644 index 0000000..eac41d5 --- /dev/null +++ b/lib/mv/membership/member/changes/sync_email_to_user.ex @@ -0,0 +1,53 @@ +defmodule Mv.Membership.Member.Changes.SyncEmailToUser do + @moduledoc """ + Synchronizes member email changes to the linked user. + + When a member's email is updated and the member is linked to a user, + this change automatically updates the user's email to match. + + This ensures bidirectional email synchronization. + + Uses `around_transaction` to guarantee atomicity - both the member + and user updates happen in the SAME database transaction. + """ + use Ash.Resource.Change + alias Mv.EmailSync.Helpers + + @impl true + def change(changeset, _opts, context) do + cond do + # Skip if already syncing to avoid recursion + Map.get(context, :syncing_email, false) -> + changeset + + # Only proceed if email is actually changing + not Ash.Changeset.changing_attribute?(changeset, :email) -> + changeset + + # Apply the sync logic + true -> + new_email = Ash.Changeset.get_attribute(changeset, :email) + + # around_transaction receives the changeset (cs) from Ash + # and a callback that executes the actual database operation + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + linked_user <- get_linked_user(member) do + Helpers.sync_email_to_linked_record(result, linked_user, new_email) + else + _ -> result + end + end) + end + end + + # Load the linked user relationship (returns nil if not linked) + defp get_linked_user(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} -> user + {:error, _} -> nil + end + end +end -- 2.47.2 From 39afaf3999f66c5353bd9519e56b4ad13e9f74d9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:21:23 +0200 Subject: [PATCH 3/9] feat: email uniqueness constraint between user and member --- lib/accounts/user.ex | 19 +- lib/membership/member.ex | 4 + .../email_not_used_by_other_member.ex | 52 +++++ .../email_not_used_by_other_user.ex | 59 +++++ test/accounts/email_uniqueness_test.exs | 201 ++++++++++++++++++ 5 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 lib/mv/accounts/user/validations/email_not_used_by_other_member.ex create mode 100644 lib/mv/membership/member/validations/email_not_used_by_other_user.ex create mode 100644 test/accounts/email_uniqueness_test.exs diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 7101e16..278e71a 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -66,14 +66,17 @@ defmodule Mv.Accounts.User do end actions do - # Default actions kept for framework/tooling integration: - # - :create -> Used by AshAdmin's generated "Create" UI and by generic - # AshPhoenix helpers that assume a default create action. - # It does NOT manage the :member relationship. For admin - # flows that may link an existing member, use :create_user. + # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - defaults [:read, :create, :destroy] + # + # NOTE: :create is INTENTIONALLY excluded from defaults! + # Using a default :create would bypass email-synchronization logic. + # Always use one of these explicit create actions instead: + # - :create_user (for manual user creation with optional member link) + # - :register_with_password (for password-based registration) + # - :register_with_rauthy (for OIDC-based registration) + defaults [:read, :destroy] # Primary generic update action: # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix @@ -209,6 +212,10 @@ defmodule Mv.Accounts.User do where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" + # Email uniqueness check for all actions that change the email attribute + # Validates that user email is not already used by another (unlinked) member + validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember + # Email validation with EctoCommons.EmailValidator (same as Member) # This ensures consistency between User and Member email validation validate fn changeset, _ -> diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 9330922..a0799fd 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -108,6 +108,10 @@ defmodule Mv.Membership.Member do validate present(:last_name) validate present(:email) + # Email uniqueness check for all actions that change the email attribute + # Validates that member email is not already used by another (unlinked) user + validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser + # 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 diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex new file mode 100644 index 0000000..cf3c624 --- /dev/null +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -0,0 +1,52 @@ +defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do + @moduledoc """ + Validates that the user's email is not already used by another member + (unless that member is linked to this user). + + This prevents email conflicts when syncing between users and members. + """ + use Ash.Resource.Validation + + @impl true + def validate(changeset, _opts, _context) do + case Ash.Changeset.fetch_change(changeset, :email) do + {:ok, new_email} -> + check_email_not_used_by_other_member(changeset, new_email) + + :error -> + # Email not being changed + :ok + end + end + + defp check_email_not_used_by_other_member(changeset, new_email) do + member_id = Ash.Changeset.get_attribute(changeset, :member_id) + + # Check if any member has this email + # Exclude the member linked to this user (if any) + query = + Mv.Membership.Member + |> Ash.Query.filter(email == ^to_string(new_email)) + |> then(fn q -> + if member_id do + Ash.Query.filter(q, id != ^member_id) + else + q + end + end) + + case Ash.read(query) do + {:ok, []} -> + # No conflicting member found + :ok + + {:ok, members} when is_list(members) and length(members) > 0 -> + # Email is already used by another member + {:error, field: :email, message: "is already used by another member", value: new_email} + + {:error, _} -> + # Error reading members - be safe and allow + :ok + end + end +end diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex new file mode 100644 index 0000000..f48613b --- /dev/null +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -0,0 +1,59 @@ +defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do + @moduledoc """ + Validates that the member's email is not already used by another user + (unless that user is linked to this member). + + This prevents email conflicts when syncing between users and members. + """ + use Ash.Resource.Validation + + @impl true + def validate(changeset, _opts, _context) do + case Ash.Changeset.fetch_change(changeset, :email) do + {:ok, new_email} -> + check_email_not_used_by_other_user(changeset, new_email) + + :error -> + # Email not being changed + :ok + end + end + + defp check_email_not_used_by_other_user(changeset, new_email) do + # Load the user relationship to check if this member is linked to a user + member_with_user = + case Ash.load(changeset.data, :user) do + {:ok, loaded} -> loaded + {:error, _} -> changeset.data + end + + linked_user_id = if member_with_user.user, do: member_with_user.user.id, else: nil + + # Check if any user has this email (case-insensitive) + # Exclude the user linked to this member (if any) + query = + Mv.Accounts.User + |> Ash.Query.filter(email == ^new_email) + |> then(fn q -> + if linked_user_id do + Ash.Query.filter(q, id != ^linked_user_id) + else + q + end + end) + + case Ash.read(query) do + {:ok, []} -> + # No conflicting user found + :ok + + {:ok, users} when is_list(users) and length(users) > 0 -> + # Email is already used by another user + {:error, field: :email, message: "is already used by another user", value: new_email} + + {:error, _} -> + # Error reading users - be safe and allow + :ok + end + end +end diff --git a/test/accounts/email_uniqueness_test.exs b/test/accounts/email_uniqueness_test.exs new file mode 100644 index 0000000..8665c48 --- /dev/null +++ b/test/accounts/email_uniqueness_test.exs @@ -0,0 +1,201 @@ +defmodule Mv.Accounts.EmailUniquenessTest do + use Mv.DataCase, async: false + + alias Mv.Accounts + alias Mv.Membership + + describe "Email uniqueness validation" do + test "cannot create member with existing unlinked user email" do + # Create a user with email + {:ok, _user} = + Accounts.create_user(%{ + email: "existing@example.com" + }) + + # Try to create member with same email + result = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "existing@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "cannot create user with existing unlinked member email" do + # Create a member with email + {:ok, _member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "existing@example.com" + }) + + # Try to create user with same email + result = + Accounts.create_user(%{ + email: "existing@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "member email cannot be changed to an existing unlinked user email" do + # Create a user with email + {:ok, user} = + Accounts.create_user(%{ + email: "existing_user@example.com" + }) + + # Create a member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + }) + + # Try to change member email to existing user email + result = + Membership.update_member(member, %{ + email: "existing_user@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "user email cannot be changed to an existing unlinked member email" do + # Create a member with email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "existing_member@example.com" + }) + + # Create a user with different email + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + # Try to change user email to existing member email + result = + Accounts.update_user(user, %{ + email: "existing_member@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "member email syncs to linked user email without validation error" do + # Create a user + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + # Create a member linked to this user + # The override change will set member.email = user.email automatically + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com", + user: %{id: user.id} + }) + + # Member email should have been overridden to user email + # This happens through our sync mechanism, which should NOT trigger + # the "email already used" validation because it's the same user + {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_link.email == "user@example.com" + end + + test "user email syncs to linked member without validation error" do + # Create a member + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + }) + + # Create a user linked to this member + # The override change will set member.email = user.email automatically + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member.id} + }) + + # Member email should have been overridden to user email + # This happens through our sync mechanism, which should NOT trigger + # the "email already used" validation because it's the same member + {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id) + assert member_after_link.email == "user@example.com" + end + + test "two unlinked users cannot have the same email" do + # Create first user + {:ok, _user1} = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + # Try to create second user with same email + result = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + assert {:error, %Ash.Error.Invalid{}} = result + end + + test "two unlinked members cannot have the same email (members have unique constraint)" do + # Create first member + {:ok, _member1} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "duplicate@example.com" + }) + + # Try to create second member with same email - should fail + result = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "duplicate@example.com" + }) + + assert {:error, %Ash.Error.Invalid{}} = result + # Members DO have a unique email constraint at database level + end + end +end -- 2.47.2 From 752272494544d409e8aaadb114bd79470d208793 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:33:25 +0200 Subject: [PATCH 4/9] refactor: email sync changes --- .../changes/override_member_email_on_link.ex | 43 +++++--------- .../user/changes/sync_email_to_member.ex | 57 ++++++------------- lib/mv/email_sync/loader.ex | 40 +++++++++++++ .../override_email_from_user_on_link.ex | 43 +++++--------- .../member/changes/sync_email_to_user.ex | 57 ++++++------------- 5 files changed, 102 insertions(+), 138 deletions(-) create mode 100644 lib/mv/email_sync/loader.ex diff --git a/lib/mv/accounts/user/changes/override_member_email_on_link.ex b/lib/mv/accounts/user/changes/override_member_email_on_link.ex index 7361718..b142a96 100644 --- a/lib/mv/accounts/user/changes/override_member_email_on_link.ex +++ b/lib/mv/accounts/user/changes/override_member_email_on_link.ex @@ -1,47 +1,30 @@ defmodule Mv.Accounts.User.Changes.OverrideMemberEmailOnLink do @moduledoc """ - Overrides member email with user email when linking a user to a member. - - When a user is linked to a member (either during creation or update), - this change ensures that the member's email is updated to match the user's email. - + Overrides member email with user email when linking. User.email is the source of truth when a link is established. - - Uses `around_transaction` to guarantee atomicity - both the user - creation/update and member email override happen in the SAME database transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do - # Skip if already syncing to avoid recursion if Map.get(context, :syncing_email, false) do changeset else - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, user.email) - else - _ -> result - end - end) + override_email(changeset) end end - # Pattern match on nil member_id - no member linked - defp get_linked_member(%{member_id: nil}), do: nil + defp override_email(changeset) do + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) - # Load linked member by ID - defp get_linked_member(%{member_id: id}) do - case Ash.get(Mv.Membership.Member, id) do - {:ok, member} -> member - {:error, _} -> nil - end + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- Loader.get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, user.email) + else + _ -> result + end + end) end end diff --git a/lib/mv/accounts/user/changes/sync_email_to_member.ex b/lib/mv/accounts/user/changes/sync_email_to_member.ex index 553ca91..a007dae 100644 --- a/lib/mv/accounts/user/changes/sync_email_to_member.ex +++ b/lib/mv/accounts/user/changes/sync_email_to_member.ex @@ -1,55 +1,32 @@ defmodule Mv.Accounts.User.Changes.SyncEmailToMember do @moduledoc """ Synchronizes user email changes to the linked member. - - When a user's email is updated and the user is linked to a member, - this change automatically updates the member's email to match. - - This ensures bidirectional email synchronization with User.email - as the source of truth. - - Uses `around_transaction` to guarantee atomicity - both the user - and member updates happen in the SAME database transaction. + Uses `around_transaction` for atomicity - both updates in the same transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do cond do - # Skip if already syncing to avoid recursion - Map.get(context, :syncing_email, false) -> - changeset - - # Only proceed if email is actually changing - not Ash.Changeset.changing_attribute?(changeset, :email) -> - changeset - - # Apply the sync logic - true -> - new_email = Ash.Changeset.get_attribute(changeset, :email) - - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, new_email) - else - _ -> result - end - end) + Map.get(context, :syncing_email, false) -> changeset + not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset + true -> sync_email(changeset) end end - defp get_linked_member(%{member_id: nil}), do: nil + defp sync_email(changeset) do + new_email = Ash.Changeset.get_attribute(changeset, :email) - defp get_linked_member(%{member_id: member_id}) do - case Ash.get(Mv.Membership.Member, member_id) do - {:ok, member} -> member - {:error, _} -> nil - end + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, user} <- Helpers.extract_record(result), + linked_member <- Loader.get_linked_member(user) do + Helpers.sync_email_to_linked_record(result, linked_member, new_email) + else + _ -> result + end + end) end end diff --git a/lib/mv/email_sync/loader.ex b/lib/mv/email_sync/loader.ex new file mode 100644 index 0000000..ecb1038 --- /dev/null +++ b/lib/mv/email_sync/loader.ex @@ -0,0 +1,40 @@ +defmodule Mv.EmailSync.Loader do + @moduledoc """ + Helper functions for loading linked records in email synchronization. + Centralizes the logic for retrieving related User/Member entities. + """ + + @doc """ + Loads the member linked to a user, returns nil if not linked or on error. + """ + def get_linked_member(%{member_id: nil}), do: nil + + def get_linked_member(%{member_id: id}) do + case Ash.get(Mv.Membership.Member, id) do + {:ok, member} -> member + {:error, _} -> nil + end + end + + @doc """ + Loads the user linked to a member, returns nil if not linked or on error. + """ + def get_linked_user(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} -> user + {:error, _} -> nil + end + end + + @doc """ + Loads the user linked to a member, returning an error tuple if not linked. + Useful when a link is required for the operation. + """ + def load_linked_user!(member) do + case Ash.load(member, :user) do + {:ok, %{user: user}} when not is_nil(user) -> {:ok, user} + {:ok, _} -> {:error, :no_linked_user} + {:error, _} = error -> error + end + end +end diff --git a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex index b55a696..f8ccd98 100644 --- a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex +++ b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex @@ -1,45 +1,30 @@ defmodule Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink do @moduledoc """ - Overrides member email with user email when linking a member to a user. - - When a member is linked to a user (either during creation or update), - this change ensures that the member's email is updated to match the user's email. - + Overrides member email with user email when linking. User.email is the source of truth when a link is established. - - Uses `around_transaction` to guarantee atomicity - both the member - creation/update and email override happen in the SAME database transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do - # Skip if already syncing to avoid recursion if Map.get(context, :syncing_email, false) do changeset else - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, member} <- Helpers.extract_record(result), - {:ok, user} <- load_linked_user(member) do - Helpers.override_with_linked_email(result, user.email) - else - _ -> result - end - end) + override_email(changeset) end end - # Load the linked user, returning error tuple if not linked - defp load_linked_user(member) do - case Ash.load(member, :user) do - {:ok, %{user: user}} when not is_nil(user) -> {:ok, user} - {:ok, _} -> {:error, :no_linked_user} - {:error, _} = error -> error - end + defp override_email(changeset) do + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + {:ok, user} <- Loader.load_linked_user!(member) do + Helpers.override_with_linked_email(result, user.email) + else + _ -> result + end + end) end end diff --git a/lib/mv/membership/member/changes/sync_email_to_user.ex b/lib/mv/membership/member/changes/sync_email_to_user.ex index eac41d5..8363584 100644 --- a/lib/mv/membership/member/changes/sync_email_to_user.ex +++ b/lib/mv/membership/member/changes/sync_email_to_user.ex @@ -1,53 +1,32 @@ defmodule Mv.Membership.Member.Changes.SyncEmailToUser do @moduledoc """ Synchronizes member email changes to the linked user. - - When a member's email is updated and the member is linked to a user, - this change automatically updates the user's email to match. - - This ensures bidirectional email synchronization. - - Uses `around_transaction` to guarantee atomicity - both the member - and user updates happen in the SAME database transaction. + Uses `around_transaction` for atomicity - both updates in the same transaction. """ use Ash.Resource.Change - alias Mv.EmailSync.Helpers + alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do cond do - # Skip if already syncing to avoid recursion - Map.get(context, :syncing_email, false) -> - changeset - - # Only proceed if email is actually changing - not Ash.Changeset.changing_attribute?(changeset, :email) -> - changeset - - # Apply the sync logic - true -> - new_email = Ash.Changeset.get_attribute(changeset, :email) - - # around_transaction receives the changeset (cs) from Ash - # and a callback that executes the actual database operation - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, member} <- Helpers.extract_record(result), - linked_user <- get_linked_user(member) do - Helpers.sync_email_to_linked_record(result, linked_user, new_email) - else - _ -> result - end - end) + Map.get(context, :syncing_email, false) -> changeset + not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset + true -> sync_email(changeset) end end - # Load the linked user relationship (returns nil if not linked) - defp get_linked_user(member) do - case Ash.load(member, :user) do - {:ok, %{user: user}} -> user - {:error, _} -> nil - end + defp sync_email(changeset) do + new_email = Ash.Changeset.get_attribute(changeset, :email) + + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, member} <- Helpers.extract_record(result), + linked_user <- Loader.get_linked_user(member) do + Helpers.sync_email_to_linked_record(result, linked_user, new_email) + else + _ -> result + end + end) end end -- 2.47.2 From 2693f67d3324ab83cfd28601cc628894f03279c6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:34:04 +0200 Subject: [PATCH 5/9] refactor: email validations --- .../email_not_used_by_other_member.ex | 32 ++++--------- .../email_not_used_by_other_user.ex | 46 +++++++------------ 2 files changed, 27 insertions(+), 51 deletions(-) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index cf3c624..d3cb776 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -1,9 +1,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do @moduledoc """ - Validates that the user's email is not already used by another member - (unless that member is linked to this user). - - This prevents email conflicts when syncing between users and members. + Validates that the user's email is not already used by another member. + Allows syncing with linked member (excludes member_id from check). """ use Ash.Resource.Validation @@ -11,42 +9,32 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do def validate(changeset, _opts, _context) do case Ash.Changeset.fetch_change(changeset, :email) do {:ok, new_email} -> - check_email_not_used_by_other_member(changeset, new_email) + member_id = Ash.Changeset.get_attribute(changeset, :member_id) + check_email_uniqueness(new_email, member_id) :error -> - # Email not being changed :ok end end - defp check_email_not_used_by_other_member(changeset, new_email) do - member_id = Ash.Changeset.get_attribute(changeset, :member_id) - - # Check if any member has this email - # Exclude the member linked to this user (if any) + defp check_email_uniqueness(new_email, exclude_member_id) do query = Mv.Membership.Member |> Ash.Query.filter(email == ^to_string(new_email)) - |> then(fn q -> - if member_id do - Ash.Query.filter(q, id != ^member_id) - else - q - end - end) + |> maybe_exclude_id(exclude_member_id) case Ash.read(query) do {:ok, []} -> - # No conflicting member found :ok - {:ok, members} when is_list(members) and length(members) > 0 -> - # Email is already used by another member + {:ok, _} -> {:error, field: :email, message: "is already used by another member", value: new_email} {:error, _} -> - # Error reading members - be safe and allow :ok end end + + defp maybe_exclude_id(query, nil), do: query + defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) end diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex index f48613b..6c544b5 100644 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -1,9 +1,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do @moduledoc """ - Validates that the member's email is not already used by another user - (unless that user is linked to this member). - - This prevents email conflicts when syncing between users and members. + Validates that the member's email is not already used by another user. + Allows syncing with linked user (excludes linked user from check). """ use Ash.Resource.Validation @@ -11,49 +9,39 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do def validate(changeset, _opts, _context) do case Ash.Changeset.fetch_change(changeset, :email) do {:ok, new_email} -> - check_email_not_used_by_other_user(changeset, new_email) + linked_user_id = get_linked_user_id(changeset.data) + check_email_uniqueness(new_email, linked_user_id) :error -> - # Email not being changed :ok end end - defp check_email_not_used_by_other_user(changeset, new_email) do - # Load the user relationship to check if this member is linked to a user - member_with_user = - case Ash.load(changeset.data, :user) do - {:ok, loaded} -> loaded - {:error, _} -> changeset.data - end - - linked_user_id = if member_with_user.user, do: member_with_user.user.id, else: nil - - # Check if any user has this email (case-insensitive) - # Exclude the user linked to this member (if any) + defp check_email_uniqueness(new_email, exclude_user_id) do query = Mv.Accounts.User |> Ash.Query.filter(email == ^new_email) - |> then(fn q -> - if linked_user_id do - Ash.Query.filter(q, id != ^linked_user_id) - else - q - end - end) + |> maybe_exclude_id(exclude_user_id) case Ash.read(query) do {:ok, []} -> - # No conflicting user found :ok - {:ok, users} when is_list(users) and length(users) > 0 -> - # Email is already used by another user + {:ok, _} -> {:error, field: :email, message: "is already used by another user", value: new_email} {:error, _} -> - # Error reading users - be safe and allow :ok end end + + defp maybe_exclude_id(query, nil), do: query + defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) + + defp get_linked_user_id(member_data) do + case Ash.load(member_data, :user) do + {:ok, %{user: %{id: id}}} -> id + _ -> nil + end + end end -- 2.47.2 From 001fca1d16d30f47e41e562b7c71b7e17e978ecb Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 16:01:48 +0200 Subject: [PATCH 6/9] refactor: email sync changes --- lib/accounts/user.ex | 29 +++++---- lib/membership/member.ex | 27 +++++--- .../changes/override_member_email_on_link.ex | 30 --------- .../user/changes/sync_email_to_member.ex | 32 ---------- .../changes/sync_member_email_to_user.ex} | 19 +++--- .../changes/sync_user_email_to_member.ex | 62 +++++++++++++++++++ .../override_email_from_user_on_link.ex | 30 --------- 7 files changed, 108 insertions(+), 121 deletions(-) delete mode 100644 lib/mv/accounts/user/changes/override_member_email_on_link.ex delete mode 100644 lib/mv/accounts/user/changes/sync_email_to_member.ex rename lib/mv/{membership/member/changes/sync_email_to_user.ex => email_sync/changes/sync_member_email_to_user.ex} (56%) create mode 100644 lib/mv/email_sync/changes/sync_user_email_to_member.ex delete mode 100644 lib/mv/membership/member/changes/override_email_from_user_on_link.ex diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 278e71a..0fc5ab0 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -93,8 +93,11 @@ defmodule Mv.Accounts.User do # complex checks that are not supported in atomic operations. require_atomic? false - # Sync email changes to linked member - change Mv.Accounts.User.Changes.SyncEmailToMember + # Sync email changes to linked member (User → Member) + # Only runs when email is being changed + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:email)] + end end create :create_user do @@ -118,8 +121,8 @@ defmodule Mv.Accounts.User do on_missing: :ignore ) - # Override member email with user email when linking - change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Sync user email to member when linking (User → Member) + change Mv.EmailSync.Changes.SyncUserEmailToMember end update :update_user do @@ -147,10 +150,11 @@ defmodule Mv.Accounts.User do on_missing: :unrelate ) - # Sync email changes to linked member - change Mv.Accounts.User.Changes.SyncEmailToMember - # Override member email with user email when linking - change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Sync email changes and handle linking (User → Member) + # Runs when email OR member relationship changes + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where any([changing(:email), changing(:member)]) + end end # Admin action for direct password changes in admin panel @@ -200,8 +204,8 @@ defmodule Mv.Accounts.User do |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end - # Override member email with user email when linking (if member relationship exists) - change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink + # Sync user email to member when linking (User → Member) + change Mv.EmailSync.Changes.SyncUserEmailToMember end end @@ -281,9 +285,8 @@ defmodule Mv.Accounts.User do # User.email is the source of truth - when a link is established, member.email # is overridden to match user.email. Subsequent changes to either email will # sync to the other resource. - # See: Mv.Accounts.User.Changes.SyncEmailToMember - # Mv.Accounts.User.Changes.OverrideMemberEmailOnLink - # Mv.Membership.Member.Changes.SyncEmailToUser + # See: Mv.EmailSync.Changes.SyncUserEmailToMember + # Mv.EmailSync.Changes.SyncMemberEmailToUser attribute :email, :ci_string do allow_nil? false public? true diff --git a/lib/membership/member.ex b/lib/membership/member.ex index a0799fd..56549fc 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -49,8 +49,11 @@ defmodule Mv.Membership.Member do on_missing: :ignore ) - # Override member email with user email when linking - change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink + # Sync user email to member when linking (User → Member) + # Only runs when user relationship is being changed + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:user)] + end end update :update_member do @@ -93,10 +96,17 @@ defmodule Mv.Membership.Member do on_missing: :unrelate ) - # Override member email with user email when linking - change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink - # Sync email changes to linked user - change Mv.Membership.Member.Changes.SyncEmailToUser + # Sync member email to user when email changes (Member → User) + # Only runs when email is being changed + change Mv.EmailSync.Changes.SyncMemberEmailToUser do + where [changing(:email)] + end + + # Sync user email to member when linking (User → Member) + # Only runs when user relationship is being changed + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:user)] + end end end @@ -206,9 +216,8 @@ defmodule Mv.Membership.Member do # User.email is the source of truth - when a link is established, member.email # is overridden to match user.email. Subsequent changes to either email will # sync to the other resource. - # See: Mv.Membership.Member.Changes.SyncEmailToUser - # Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink - # Mv.Accounts.User.Changes.SyncEmailToMember + # See: Mv.EmailSync.Changes.SyncUserEmailToMember + # Mv.EmailSync.Changes.SyncMemberEmailToUser attribute :email, :string do allow_nil? false constraints min_length: 5, max_length: 254 diff --git a/lib/mv/accounts/user/changes/override_member_email_on_link.ex b/lib/mv/accounts/user/changes/override_member_email_on_link.ex deleted file mode 100644 index b142a96..0000000 --- a/lib/mv/accounts/user/changes/override_member_email_on_link.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Mv.Accounts.User.Changes.OverrideMemberEmailOnLink do - @moduledoc """ - Overrides member email with user email when linking. - User.email is the source of truth when a link is established. - """ - use Ash.Resource.Change - alias Mv.EmailSync.{Helpers, Loader} - - @impl true - def change(changeset, _opts, context) do - if Map.get(context, :syncing_email, false) do - changeset - else - override_email(changeset) - end - end - - defp override_email(changeset) do - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- Loader.get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, user.email) - else - _ -> result - end - end) - end -end diff --git a/lib/mv/accounts/user/changes/sync_email_to_member.ex b/lib/mv/accounts/user/changes/sync_email_to_member.ex deleted file mode 100644 index a007dae..0000000 --- a/lib/mv/accounts/user/changes/sync_email_to_member.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Mv.Accounts.User.Changes.SyncEmailToMember do - @moduledoc """ - Synchronizes user email changes to the linked member. - Uses `around_transaction` for atomicity - both updates in the same transaction. - """ - use Ash.Resource.Change - alias Mv.EmailSync.{Helpers, Loader} - - @impl true - def change(changeset, _opts, context) do - cond do - Map.get(context, :syncing_email, false) -> changeset - not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset - true -> sync_email(changeset) - end - end - - defp sync_email(changeset) do - new_email = Ash.Changeset.get_attribute(changeset, :email) - - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, user} <- Helpers.extract_record(result), - linked_member <- Loader.get_linked_member(user) do - Helpers.sync_email_to_linked_record(result, linked_member, new_email) - else - _ -> result - end - end) - end -end diff --git a/lib/mv/membership/member/changes/sync_email_to_user.ex b/lib/mv/email_sync/changes/sync_member_email_to_user.ex similarity index 56% rename from lib/mv/membership/member/changes/sync_email_to_user.ex rename to lib/mv/email_sync/changes/sync_member_email_to_user.ex index 8363584..c1e5aea 100644 --- a/lib/mv/membership/member/changes/sync_email_to_user.ex +++ b/lib/mv/email_sync/changes/sync_member_email_to_user.ex @@ -1,17 +1,22 @@ -defmodule Mv.Membership.Member.Changes.SyncEmailToUser do +defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do @moduledoc """ - Synchronizes member email changes to the linked user. - Uses `around_transaction` for atomicity - both updates in the same transaction. + Synchronizes Member.email → User.email + + Trigger conditions are configured in resources via `where` clauses: + - Member resource: Use `where: [changing(:email)]` + + Used by Member resource for bidirectional email sync. """ use Ash.Resource.Change alias Mv.EmailSync.{Helpers, Loader} @impl true def change(changeset, _opts, context) do - cond do - Map.get(context, :syncing_email, false) -> changeset - not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset - true -> sync_email(changeset) + # Only recursion protection needed - trigger logic is in `where` clauses + if Map.get(context, :syncing_email, false) do + changeset + else + sync_email(changeset) end end diff --git a/lib/mv/email_sync/changes/sync_user_email_to_member.ex b/lib/mv/email_sync/changes/sync_user_email_to_member.ex new file mode 100644 index 0000000..be7dd2c --- /dev/null +++ b/lib/mv/email_sync/changes/sync_user_email_to_member.ex @@ -0,0 +1,62 @@ +defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do + @moduledoc """ + Synchronizes User.email → Member.email + User.email is always the source of truth. + + Trigger conditions are configured in resources via `where` clauses: + - User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])` + - Member resource: Use `where: [changing(:user)]` + + Can be used by both User and Member resources. + """ + use Ash.Resource.Change + alias Mv.EmailSync.{Helpers, Loader} + + @impl true + def change(changeset, _opts, context) do + # Only recursion protection needed - trigger logic is in `where` clauses + if Map.get(context, :syncing_email, false) do + changeset + else + sync_email(changeset) + end + end + + defp sync_email(changeset) do + Ash.Changeset.around_transaction(changeset, fn cs, callback -> + result = callback.(cs) + + with {:ok, record} <- Helpers.extract_record(result), + {:ok, user, member} <- get_user_and_member(record) do + # When called from Member-side, we need to update the member in the result + # When called from User-side, we update the linked member in DB only + case record do + %Mv.Membership.Member{} -> + # Member-side: Override member email in result with user email + Helpers.override_with_linked_email(result, user.email) + + %Mv.Accounts.User{} -> + # User-side: Sync user email to linked member in DB + Helpers.sync_email_to_linked_record(result, member, user.email) + end + else + _ -> result + end + end) + end + + # Retrieves user and member - works for both resource types + defp get_user_and_member(%Mv.Accounts.User{} = user) do + case Loader.get_linked_member(user) do + nil -> {:error, :no_member} + member -> {:ok, user, member} + end + end + + defp get_user_and_member(%Mv.Membership.Member{} = member) do + case Loader.load_linked_user!(member) do + {:ok, user} -> {:ok, user, member} + error -> error + end + end +end diff --git a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex b/lib/mv/membership/member/changes/override_email_from_user_on_link.ex deleted file mode 100644 index f8ccd98..0000000 --- a/lib/mv/membership/member/changes/override_email_from_user_on_link.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink do - @moduledoc """ - Overrides member email with user email when linking. - User.email is the source of truth when a link is established. - """ - use Ash.Resource.Change - alias Mv.EmailSync.{Helpers, Loader} - - @impl true - def change(changeset, _opts, context) do - if Map.get(context, :syncing_email, false) do - changeset - else - override_email(changeset) - end - end - - defp override_email(changeset) do - Ash.Changeset.around_transaction(changeset, fn cs, callback -> - result = callback.(cs) - - with {:ok, member} <- Helpers.extract_record(result), - {:ok, user} <- Loader.load_linked_user!(member) do - Helpers.override_with_linked_email(result, user.email) - else - _ -> result - end - end) - end -end -- 2.47.2 From 1495ef45927657d233338bdfbf4eade2c96e6f19 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 20 Oct 2025 14:38:00 +0200 Subject: [PATCH 7/9] fix validation behaviour --- .../email_not_used_by_other_member.ex | 40 +- .../email_not_used_by_other_user.ex | 31 +- test/accounts/email_uniqueness_test.exs | 357 ++++++++++++++++-- 3 files changed, 369 insertions(+), 59 deletions(-) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index d3cb776..d42b2c1 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -1,26 +1,46 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do @moduledoc """ Validates that the user's email is not already used by another member. - Allows syncing with linked member (excludes member_id from check). + Only validates when: + - User is already linked to a member (member_id != nil) AND email is changing + - User is being linked to a member (member relationship is changing) + + This allows creating users with the same email as unlinked members. """ use Ash.Resource.Validation @impl true def validate(changeset, _opts, _context) do - case Ash.Changeset.fetch_change(changeset, :email) do - {:ok, new_email} -> - member_id = Ash.Changeset.get_attribute(changeset, :member_id) - check_email_uniqueness(new_email, member_id) + email_changing? = Ash.Changeset.changing_attribute?(changeset, :email) + member_changing? = Ash.Changeset.changing_relationship?(changeset, :member) - :error -> - :ok + member_id = Ash.Changeset.get_attribute(changeset, :member_id) + is_linked? = not is_nil(member_id) + + # Only validate if: + # 1. User is linked AND email is changing + # 2. User is being linked/unlinked (member relationship changing) + should_validate? = (is_linked? and email_changing?) or member_changing? + + if should_validate? do + case Ash.Changeset.fetch_change(changeset, :email) do + {:ok, new_email} -> + check_email_uniqueness(new_email, member_id) + + :error -> + # No email change, get current email + current_email = Ash.Changeset.get_attribute(changeset, :email) + check_email_uniqueness(current_email, member_id) + end + else + :ok end end - defp check_email_uniqueness(new_email, exclude_member_id) do + defp check_email_uniqueness(email, exclude_member_id) do query = Mv.Membership.Member - |> Ash.Query.filter(email == ^to_string(new_email)) + |> Ash.Query.filter(email == ^to_string(email)) |> maybe_exclude_id(exclude_member_id) case Ash.read(query) do @@ -28,7 +48,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do :ok {:ok, _} -> - {:error, field: :email, message: "is already used by another member", value: new_email} + {:error, field: :email, message: "is already used by another member", value: email} {:error, _} -> :ok diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex index 6c544b5..54fa243 100644 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -1,26 +1,37 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do @moduledoc """ Validates that the member's email is not already used by another user. - Allows syncing with linked user (excludes linked user from check). + Only validates when: + - Member is already linked to a user (user != nil) AND email is changing + - Member is being linked to a user (user relationship is changing) + + This allows creating members with the same email as unlinked users. """ use Ash.Resource.Validation @impl true def validate(changeset, _opts, _context) do - case Ash.Changeset.fetch_change(changeset, :email) do - {:ok, new_email} -> - linked_user_id = get_linked_user_id(changeset.data) - check_email_uniqueness(new_email, linked_user_id) + email_changing? = Ash.Changeset.changing_attribute?(changeset, :email) - :error -> - :ok + linked_user_id = get_linked_user_id(changeset.data) + is_linked? = not is_nil(linked_user_id) + + # Only validate if member is already linked AND email is changing + # Do NOT validate when member is being linked (email will be overridden from user) + should_validate? = is_linked? and email_changing? + + if should_validate? do + new_email = Ash.Changeset.get_attribute(changeset, :email) + check_email_uniqueness(new_email, linked_user_id) + else + :ok end end - defp check_email_uniqueness(new_email, exclude_user_id) do + defp check_email_uniqueness(email, exclude_user_id) do query = Mv.Accounts.User - |> Ash.Query.filter(email == ^new_email) + |> Ash.Query.filter(email == ^email) |> maybe_exclude_id(exclude_user_id) case Ash.read(query) do @@ -28,7 +39,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do :ok {:ok, _} -> - {:error, field: :email, message: "is already used by another user", value: new_email} + {:error, field: :email, message: "is already used by another user", value: email} {:error, _} -> :ok diff --git a/test/accounts/email_uniqueness_test.exs b/test/accounts/email_uniqueness_test.exs index 8665c48..a16ebdd 100644 --- a/test/accounts/email_uniqueness_test.exs +++ b/test/accounts/email_uniqueness_test.exs @@ -4,32 +4,26 @@ defmodule Mv.Accounts.EmailUniquenessTest do alias Mv.Accounts alias Mv.Membership - describe "Email uniqueness validation" do - test "cannot create member with existing unlinked user email" do + describe "Email uniqueness validation - Creation" do + test "CAN create member with existing unlinked user email" do # Create a user with email {:ok, _user} = Accounts.create_user(%{ email: "existing@example.com" }) - # Try to create member with same email - result = + # Create member with same email - should succeed + {:ok, member} = Membership.create_member(%{ first_name: "John", last_name: "Doe", email: "existing@example.com" }) - assert {:error, %Ash.Error.Invalid{} = error} = result - - assert error.errors - |> Enum.any?(fn e -> - e.field == :email and - (String.contains?(e.message, "already") or String.contains?(e.message, "used")) - end) + assert to_string(member.email) == "existing@example.com" end - test "cannot create user with existing unlinked member email" do + test "CAN create user with existing unlinked member email" do # Create a member with email {:ok, _member} = Membership.create_member(%{ @@ -38,29 +32,25 @@ defmodule Mv.Accounts.EmailUniquenessTest do email: "existing@example.com" }) - # Try to create user with same email - result = + # Create user with same email - should succeed + {:ok, user} = Accounts.create_user(%{ email: "existing@example.com" }) - assert {:error, %Ash.Error.Invalid{} = error} = result - - assert error.errors - |> Enum.any?(fn e -> - e.field == :email and - (String.contains?(e.message, "already") or String.contains?(e.message, "used")) - end) + assert to_string(user.email) == "existing@example.com" end + end - test "member email cannot be changed to an existing unlinked user email" do + describe "Email uniqueness validation - Updating unlinked entities" do + test "unlinked member email CAN be changed to an existing unlinked user email" do # Create a user with email - {:ok, user} = + {:ok, _user} = Accounts.create_user(%{ email: "existing_user@example.com" }) - # Create a member with different email + # Create an unlinked member with different email {:ok, member} = Membership.create_member(%{ first_name: "John", @@ -68,42 +58,68 @@ defmodule Mv.Accounts.EmailUniquenessTest do email: "member@example.com" }) - # Try to change member email to existing user email - result = + # Change member email to existing user email - should succeed (member is unlinked) + {:ok, updated_member} = Membership.update_member(member, %{ email: "existing_user@example.com" }) - assert {:error, %Ash.Error.Invalid{} = error} = result - - assert error.errors - |> Enum.any?(fn e -> - e.field == :email and - (String.contains?(e.message, "already") or String.contains?(e.message, "used")) - end) + assert to_string(updated_member.email) == "existing_user@example.com" end - test "user email cannot be changed to an existing unlinked member email" do + test "unlinked user email CAN be changed to an existing unlinked member email" do # Create a member with email - {:ok, member} = + {:ok, _member} = Membership.create_member(%{ first_name: "John", last_name: "Doe", email: "existing_member@example.com" }) - # Create a user with different email + # Create an unlinked user with different email {:ok, user} = Accounts.create_user(%{ email: "user@example.com" }) - # Try to change user email to existing member email - result = + # Change user email to existing member email - should succeed (user is unlinked) + {:ok, updated_user} = Accounts.update_user(user, %{ email: "existing_member@example.com" }) + assert to_string(updated_user.email) == "existing_member@example.com" + end + + test "unlinked member email CANNOT be changed to an existing linked user email" do + # Create a user and link it to a member - this makes the user "linked" + {:ok, user} = + Accounts.create_user(%{ + email: "linked_user@example.com" + }) + + {:ok, _member_a} = + Membership.create_member(%{ + first_name: "Member", + last_name: "A", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Create an unlinked member with different email + {:ok, member_b} = + Membership.create_member(%{ + first_name: "Member", + last_name: "B", + email: "member_b@example.com" + }) + + # Try to change unlinked member's email to linked user's email - should fail + result = + Membership.update_member(member_b, %{ + email: "linked_user@example.com" + }) + assert {:error, %Ash.Error.Invalid{} = error} = result assert error.errors @@ -113,6 +129,269 @@ defmodule Mv.Accounts.EmailUniquenessTest do end) end + test "unlinked user email CANNOT be changed to an existing linked member email" do + # Create a user and link it to a member - this makes the member "linked" + {:ok, user_a} = + Accounts.create_user(%{ + email: "user_a@example.com" + }) + + {:ok, _member_a} = + Membership.create_member(%{ + first_name: "Member", + last_name: "A", + email: "temp@example.com", + user: %{id: user_a.id} + }) + + # Reload user to get updated member_id and linked member email + {:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id) + {:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member) + linked_member_email = to_string(user_a_with_member.member.email) + + # Create an unlinked user with different email + {:ok, user_b} = + Accounts.create_user(%{ + email: "user_b@example.com" + }) + + # Try to change unlinked user's email to linked member's email - should fail + result = + Accounts.update_user(user_b, %{ + email: linked_member_email + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + end + + describe "Email uniqueness validation - Creating with linked emails" do + test "CANNOT create member with existing linked user email" do + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "linked@example.com" + }) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "First", + last_name: "Member", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Try to create a new member with the linked user's email - should fail + result = + Membership.create_member(%{ + first_name: "Second", + last_name: "Member", + email: "linked@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "CANNOT create user with existing linked member email" do + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "Member", + last_name: "One", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Reload user to get the linked member's email + {:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id) + {:ok, user_with_member} = Ash.load(user_reloaded, :member) + linked_member_email = to_string(user_with_member.member.email) + + # Try to create a new user with the linked member's email - should fail + result = + Accounts.create_user(%{ + email: linked_member_email + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + end + + describe "Email uniqueness validation - Updating linked entities" do + test "linked member email CANNOT be changed to an existing user email" do + # Create a user with email + {:ok, _other_user} = + Accounts.create_user(%{ + email: "other_user@example.com" + }) + + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Try to change linked member's email to other user's email - should fail + result = + Membership.update_member(member, %{ + email: "other_user@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "linked user email CANNOT be changed to an existing member email" do + # Create a member with email + {:ok, _other_member} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Doe", + email: "other_member@example.com" + }) + + # Create a user and link it to a member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "temp@example.com", + user: %{id: user.id} + }) + + # Reload user to get updated member_id + {:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id) + + # Try to change linked user's email to other member's email - should fail + result = + Accounts.update_user(user_reloaded, %{ + email: "other_member@example.com" + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + end + + describe "Email uniqueness validation - Linking" do + test "CANNOT link user to member if user email is already used by another unlinked member" do + # Create a member with email + {:ok, _other_member} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Doe", + email: "duplicate@example.com" + }) + + # Create a user with same email + {:ok, user} = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + # Create a member to link with the user + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Smith", + email: "john@example.com" + }) + + # Try to link user to member - should fail because user.email is already used by other_member + result = + Accounts.update_user(user, %{ + member: %{id: member.id} + }) + + assert {:error, %Ash.Error.Invalid{} = error} = result + + assert error.errors + |> Enum.any?(fn e -> + e.field == :email and + (String.contains?(e.message, "already") or String.contains?(e.message, "used")) + end) + end + + test "CAN link member to user even if member email is used by another user (member email gets overridden)" do + # Create a user with email + {:ok, _other_user} = + Accounts.create_user(%{ + email: "duplicate@example.com" + }) + + # Create a member with same email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "duplicate@example.com" + }) + + # Create a user to link with the member + {:ok, user} = + Accounts.create_user(%{ + email: "user@example.com" + }) + + # Link member to user - should succeed because member.email will be overridden + {:ok, updated_member} = + Membership.update_member(member, %{ + user: %{id: user.id} + }) + + # Member email should now be the same as user email + {:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id) + assert to_string(member_reloaded.email) == "user@example.com" + end + end + + describe "Email syncing" do test "member email syncs to linked user email without validation error" do # Create a user {:ok, user} = @@ -148,7 +427,7 @@ defmodule Mv.Accounts.EmailUniquenessTest do # Create a user linked to this member # The override change will set member.email = user.email automatically - {:ok, user} = + {:ok, _user} = Accounts.create_user(%{ email: "user@example.com", member: %{id: member.id} -- 2.47.2 From 37fcc26b22396b95f18fccc9abfc82fddb3531ea Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 20 Oct 2025 14:53:38 +0200 Subject: [PATCH 8/9] add seed test --- test/seeds_test.exs | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/seeds_test.exs diff --git a/test/seeds_test.exs b/test/seeds_test.exs new file mode 100644 index 0000000..5c589ae --- /dev/null +++ b/test/seeds_test.exs @@ -0,0 +1,46 @@ +defmodule Mv.SeedsTest do + use Mv.DataCase, async: false + + describe "Seeds script" do + test "runs successfully without errors" do + # Run the seeds script - should not raise any errors + assert Code.eval_file("priv/repo/seeds.exs") + + # Basic smoke test: ensure some data was created + {:ok, users} = Ash.read(Mv.Accounts.User) + {:ok, members} = Ash.read(Mv.Membership.Member) + {:ok, property_types} = Ash.read(Mv.Membership.PropertyType) + + assert length(users) > 0, "Seeds should create at least one user" + assert length(members) > 0, "Seeds should create at least one member" + assert length(property_types) > 0, "Seeds should create at least one property type" + end + + test "can be run multiple times (idempotent)" do + # Run seeds first time + assert Code.eval_file("priv/repo/seeds.exs") + + # Count records + {:ok, users_count_1} = Ash.read(Mv.Accounts.User) + {:ok, members_count_1} = Ash.read(Mv.Membership.Member) + {:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType) + + # Run seeds second time - should not raise errors + assert Code.eval_file("priv/repo/seeds.exs") + + # Count records again - should be the same (upsert, not duplicate) + {:ok, users_count_2} = Ash.read(Mv.Accounts.User) + {:ok, members_count_2} = Ash.read(Mv.Membership.Member) + {:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType) + + assert length(users_count_1) == length(users_count_2), + "Users count should remain same after re-running seeds" + + assert length(members_count_1) == length(members_count_2), + "Members count should remain same after re-running seeds" + + assert length(property_types_count_1) == length(property_types_count_2), + "PropertyTypes count should remain same after re-running seeds" + end + end +end -- 2.47.2 From 899039b3ee720e8712be9f00d8ba1bd013b83d14 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 23 Oct 2025 13:13:12 +0200 Subject: [PATCH 9/9] add docs --- docs/email-sync.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/email-sync.md diff --git a/docs/email-sync.md b/docs/email-sync.md new file mode 100644 index 0000000..c191ff4 --- /dev/null +++ b/docs/email-sync.md @@ -0,0 +1,49 @@ +## Core Rules + +1. **User.email is source of truth** - Always overrides member email when linking +2. **DB constraints** - Prevent duplicates within same table (users.email, members.email) +3. **Custom validations** - Prevent cross-table conflicts only for linked entities +4. **Sync is bidirectional**: User ↔ Member (but User always wins on link) + +--- + +## Decision Tree + +``` +Action: Create/Update/Link Entity with Email X +│ +├─ Does Email X violate DB constraint (same table)? +│ └─ YES → ❌ FAIL (two users or two members with same email) +│ +├─ Is Entity currently linked? (or being linked?) +│ │ +│ ├─ NO (unlinked entity) +│ │ └─ ✅ SUCCESS (no custom validation) +│ │ +│ └─ YES (linked or linking) +│ │ +│ ├─ Action: Update Linked User Email +│ │ ├─ Email used by other member? → ❌ FAIL (validation) +│ │ └─ Email unique? → ✅ SUCCESS + sync to member +│ │ +│ ├─ Action: Update Linked Member Email +│ │ ├─ Email used by other user? → ❌ FAIL (validation) +│ │ └─ Email unique? → ✅ SUCCESS + sync to user +│ │ +│ ├─ Action: Link User to Member (both directions) +│ │ ├─ User email used by other member? → ❌ FAIL (validation) +│ │ └─ Otherwise → ✅ SUCCESS + override member email + +``` + +## Sync Triggers + +| Action | Sync Direction | When | +|--------|---------------|------| +| Update linked user email | User → Member | Email changed | +| Update linked member email | Member → User | Email changed | +| Link user to member | User → Member | Always (override) | +| Link member to user | User → Member | Always (override) | +| Unlink | None | Emails stay as-is | + + -- 2.47.2