From 5a0a261cd602a6d72b877c6e82c017936285e349 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 17:51:31 +0200 Subject: [PATCH] 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