refactor: email sync changes
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Moritz 2025-10-17 16:01:48 +02:00
parent c9204ae02e
commit 387a627783
Signed by: moritz
GPG key ID: 1020A035E5DD0824
7 changed files with 108 additions and 121 deletions

View file

@ -93,8 +93,11 @@ defmodule Mv.Accounts.User do
# 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 # Sync email changes to linked member (User → Member)
change Mv.Accounts.User.Changes.SyncEmailToMember # Only runs when email is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
end end
create :create_user do create :create_user do
@ -118,8 +121,8 @@ defmodule Mv.Accounts.User do
on_missing: :ignore on_missing: :ignore
) )
# Override member email with user email when linking # Sync user email to member when linking (User → Member)
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink change Mv.EmailSync.Changes.SyncUserEmailToMember
end end
update :update_user do update :update_user do
@ -147,10 +150,11 @@ defmodule Mv.Accounts.User do
on_missing: :unrelate on_missing: :unrelate
) )
# Sync email changes to linked member # Sync email changes and handle linking (User → Member)
change Mv.Accounts.User.Changes.SyncEmailToMember # Runs when email OR member relationship changes
# Override member email with user email when linking change Mv.EmailSync.Changes.SyncUserEmailToMember do
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink where any([changing(:email), changing(:member)])
end
end end
# Admin action for direct password changes in admin panel # 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"]) |> 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) # Sync user email to member when linking (User → Member)
change Mv.Accounts.User.Changes.OverrideMemberEmailOnLink change Mv.EmailSync.Changes.SyncUserEmailToMember
end end
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 # 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 # is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource. # sync to the other resource.
# See: Mv.Accounts.User.Changes.SyncEmailToMember # See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.Accounts.User.Changes.OverrideMemberEmailOnLink # Mv.EmailSync.Changes.SyncMemberEmailToUser
# 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

View file

@ -49,8 +49,11 @@ defmodule Mv.Membership.Member do
on_missing: :ignore on_missing: :ignore
) )
# Override member email with user email when linking # Sync user email to member when linking (User → Member)
change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink # Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end end
update :update_member do update :update_member do
@ -93,10 +96,17 @@ defmodule Mv.Membership.Member do
on_missing: :unrelate on_missing: :unrelate
) )
# Override member email with user email when linking # Sync member email to user when email changes (Member → User)
change Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink # Only runs when email is being changed
# Sync email changes to linked user change Mv.EmailSync.Changes.SyncMemberEmailToUser do
change Mv.Membership.Member.Changes.SyncEmailToUser 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
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 # 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 # is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource. # sync to the other resource.
# See: Mv.Membership.Member.Changes.SyncEmailToUser # See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.Membership.Member.Changes.OverrideEmailFromUserOnLink # Mv.EmailSync.Changes.SyncMemberEmailToUser
# 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

View file

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

View file

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

View file

@ -1,17 +1,22 @@
defmodule Mv.Membership.Member.Changes.SyncEmailToUser do defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
@moduledoc """ @moduledoc """
Synchronizes member email changes to the linked user. Synchronizes Member.email User.email
Uses `around_transaction` for atomicity - both updates in the same transaction.
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 use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader} alias Mv.EmailSync.{Helpers, Loader}
@impl true @impl true
def change(changeset, _opts, context) do def change(changeset, _opts, context) do
cond do # Only recursion protection needed - trigger logic is in `where` clauses
Map.get(context, :syncing_email, false) -> changeset if Map.get(context, :syncing_email, false) do
not Ash.Changeset.changing_attribute?(changeset, :email) -> changeset changeset
true -> sync_email(changeset) else
sync_email(changeset)
end end
end end

View file

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

View file

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