sync email between user and member closes #167 #181
7 changed files with 331 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
55
lib/mv/accounts/user/changes/sync_email_to_member.ex
Normal file
55
lib/mv/accounts/user/changes/sync_email_to_member.ex
Normal file
|
|
@ -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
|
||||
93
lib/mv/email_sync/helpers.ex
Normal file
93
lib/mv/email_sync/helpers.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
53
lib/mv/membership/member/changes/sync_email_to_user.ex
Normal file
53
lib/mv/membership/member/changes/sync_email_to_user.ex
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue
Maybe you can explain that to us a bit in the next weekly :)
In Ash, if you call another Ash action within a
around_transactioncallback, it would run in a separate transaction. This can lead to inconsistencies if, for example, the first action is successful, but the email synchronization fails. By using Ecto directly (Mv.Repo.update()) the update remains in the same transaction as the parent action. If anything fails, the entire transaction is rolled back.