From dbe1df73fdb105a9ff59174dae3336b81cdf9af8 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 16:57:34 +0200 Subject: [PATCH 01/13] 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 From 6a397c962d6ee831e73dec7e2849c409f4f8de9c Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 17:51:31 +0200 Subject: [PATCH 02/13] 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 From b97c3a121bca9cec8b939cdeb61f9a777deaca68 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:21:23 +0200 Subject: [PATCH 03/13] 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 From c7a56b201a50f1976059874340953613eb80ce78 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:33:25 +0200 Subject: [PATCH 04/13] 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 From c9204ae02ea5b039558f298af9786313ae0d4999 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:34:04 +0200 Subject: [PATCH 05/13] 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 From 387a627783fafa39126105dc9638cad79ba2f688 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 16:01:48 +0200 Subject: [PATCH 06/13] 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 From 210224626da5c144914f787c8b73f9a7701098f3 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 17 Oct 2025 14:09:15 +0000 Subject: [PATCH 07/13] chore(deps): update mix dependencies --- mix.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mix.lock b/mix.lock index ccc1e18..28683a3 100644 --- a/mix.lock +++ b/mix.lock @@ -2,10 +2,10 @@ "ash": {:hex, :ash, "3.7.1", "abb55dee19e0959e529e52fe0622468825ae05400f535484919713e492d9a9e7", [:mix], [{:crux, "~> 0.1.0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4474ce9befe9862d1ed73cadf8a755e836c45a14a7b3b952d02e1a12f2b2e529"}, "ash_admin": {:hex, :ash_admin, "0.13.19", "43227905381ea0b835039fb3f3d255a3664925619937869e605402bc2f95c5e5", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "41e6262c437164df6f052e43cc93be225a7e148b49a813fc451e70172338ee38"}, "ash_authentication": {:hex, :ash_authentication, "4.11.0", "4165ede37e179cb0a24b7bfc38d620fa93c05fb6272fbd353cafe27652b1e68b", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "8201d0169944c1df3db9b560494e50e1c3bc99c3b1a8a2ef1e61b0f77bc820df"}, - "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.11.0", "3dbc3f241fd406a0c072b66e18ac6d38db30eb4220fcdd4ea243b0f5722203c8", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 4.9.1 and < 5.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3a70376368d550f44cec10d9653aaa0b4dd654f662e4cca407b4c95f4ab3512d"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.3.16", "d96bd84087c5fcd7b78f7be61754c90bac89ffc7ccc1aa9050918277a7b1fc4e", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "75c2affee727cd3963146afe5b6b6a9571373afd721a693c6d1694f70075f9fc"}, - "ash_postgres": {:hex, :ash_postgres, "2.6.20", "08a5288d710633b62714b0396e0a42689a95a5a8cdb7f56a65c8ee6f8c41bffb", [:mix], [{:ash, ">= 3.5.35 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.90 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.14 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "13d50c216c81516133439b86294f467ab65283007eb8badd0388e9fb4b461423"}, - "ash_sql": {:hex, :ash_sql, "0.3.0", "2c43ddcc8c7fb51dc25ba3bca965d8b68e7aaecb290cabfed3cf213965aca937", [:mix], [{:ash, ">= 3.5.43 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ef03759d6a1d4cb189fcadbd183cf047f0565d7d88ea8612d85c9e6e724835e7"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.0", "75d7d77e3b626f3d8ea6ee44291d885950172ab399d997b2934f93d2e0a55a61", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "a423e22b40fdf3b1a7f2178e44ca68f48fdb5ba0d87e8d42a43de1a3b63ca704"}, + "ash_phoenix": {:hex, :ash_phoenix, "2.3.17", "a074ae6d9d7135d99c4edc91ddebe4c035ca380b044592bf9c3d58471669cf52", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "94e4a6cc6ced31cddba930c45c1c3477aa59b956e7fc3cdc63095cf0e506bdf5"}, + "ash_postgres": {:hex, :ash_postgres, "2.6.23", "5976a7e5e204b7bc627b1d17026bec9da4d880f2e09cd94bf4e8cee41fef32ce", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "61de4aedfe30f1ae14d8185cfc37a5b1940b45b60f2dfbdf9eb056f97dca41c5"}, + "ash_sql": {:hex, :ash_sql, "0.3.7", "80affa5446075d71deb157c67290685a84b392d723be766bfb684f58fe0143de", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ce4d974b8e784171c5a2a62593b3672b42dfd4888fa2239f01a6b32bad769038"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, @@ -14,7 +14,7 @@ "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"}, "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, @@ -42,7 +42,7 @@ "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "live_debugger": {:hex, :live_debugger, "0.4.1", "0065bed085e90053f703e8541b646e4c762794588bba79ea557277e00e4ea07b", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "218459e3d1de32d63d3e06919ea0a836f2c3a668de9d09b4432c82bd4c68e6ab"}, + "live_debugger": {:hex, :live_debugger, "0.4.2", "775c3a570ef3c44d27d261b3c1aae23ef35cac949a57f67b3e7b1aa1fb2707bc", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "5b24e37985f0424056a322a18dab4a5fb0f4e8ee4e55975985364e0b45d683b9"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, @@ -67,7 +67,7 @@ "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, - "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"}, + "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"}, "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, @@ -75,7 +75,7 @@ "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, - "tailwind": {:hex, :tailwind, "0.4.0", "4b2606713080437e3d94a0fa26527e7425737abc001f172b484b42f43e3274c0", [:mix], [], "hexpm", "530bd35699333f8ea0e9038d7146c2f0932dfec2e3636bd4a8016380c4bc382e"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, From 815a7a42e7d90395ee0d0f6e38a8fac99520acdf Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 16:57:34 +0200 Subject: [PATCH 08/13] 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 From 8859ae0ffe70e1f2ad0977bd562d293416b6746b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 17:51:31 +0200 Subject: [PATCH 09/13] 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 From 0f0dbe2ed3df4792a4a03d73a512ed9496c7deee Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:21:23 +0200 Subject: [PATCH 10/13] 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 From a602108e4f12a4ac1912d9fa7242589b1dd0bbb6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:33:25 +0200 Subject: [PATCH 11/13] 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 From c5dfcbfa11336874845e2a2f2808aeaeaff52e4d Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 14:34:04 +0200 Subject: [PATCH 12/13] 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 From df8cc74d11466acf36749f8d332cb175bf4fb50d Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 16:01:48 +0200 Subject: [PATCH 13/13] 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