add action changes for email sync

This commit is contained in:
Moritz 2025-10-16 17:51:31 +02:00 committed by moritz
parent 7df34ce5ea
commit f390c5a965
7 changed files with 331 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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

View 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