add action changes for email sync
This commit is contained in:
parent
91c5e17994
commit
5a0a261cd6
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
|
# cannot be executed atomically. These validations need to query the database and perform
|
||||||
# complex checks that are not supported in atomic operations.
|
# complex checks that are not supported in atomic operations.
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
|
|
||||||
|
# Sync email changes to linked member
|
||||||
|
change Mv.Accounts.User.Changes.SyncEmailToMember
|
||||||
end
|
end
|
||||||
|
|
||||||
create :create_user do
|
create :create_user do
|
||||||
|
|
@ -111,6 +114,9 @@ defmodule Mv.Accounts.User do
|
||||||
# If no member provided, that's fine (optional relationship)
|
# If no member provided, that's fine (optional relationship)
|
||||||
on_missing: :ignore
|
on_missing: :ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Override member email with user email when linking
|
||||||
|
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update_user do
|
update :update_user do
|
||||||
|
|
@ -137,6 +143,11 @@ defmodule Mv.Accounts.User do
|
||||||
# If no member provided, remove existing relationship (allows member removal)
|
# If no member provided, remove existing relationship (allows member removal)
|
||||||
on_missing: :unrelate
|
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
|
end
|
||||||
|
|
||||||
# Admin action for direct password changes in admin panel
|
# 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(:email, user_info["preferred_username"])
|
||||||
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Override member email with user email when linking (if member relationship exists)
|
||||||
|
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -255,6 +269,14 @@ defmodule Mv.Accounts.User do
|
||||||
attributes do
|
attributes do
|
||||||
uuid_primary_key :id
|
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
|
attribute :email, :ci_string do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
public? true
|
public? true
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ defmodule Mv.Membership.Member do
|
||||||
# If no user provided, that's fine (optional relationship)
|
# If no user provided, that's fine (optional relationship)
|
||||||
on_missing: :ignore
|
on_missing: :ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Override member email with user email when linking
|
||||||
|
change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update_member do
|
update :update_member do
|
||||||
|
|
@ -89,6 +92,11 @@ defmodule Mv.Membership.Member do
|
||||||
# If no user provided, remove existing relationship (allows user removal)
|
# If no user provided, remove existing relationship (allows user removal)
|
||||||
on_missing: :unrelate
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -189,6 +197,14 @@ defmodule Mv.Membership.Member do
|
||||||
constraints min_length: 1
|
constraints min_length: 1
|
||||||
end
|
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
|
attribute :email, :string do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
constraints min_length: 5, max_length: 254
|
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