From 001fca1d16d30f47e41e562b7c71b7e17e978ecb Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 17 Oct 2025 16:01:48 +0200 Subject: [PATCH] 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