refactor: implement proper notification handling via after_action hooks

Refactor notification handling according to Ash best practices
This commit is contained in:
Moritz 2025-12-15 15:56:32 +01:00
parent f7c33bfc7d
commit 4997493139
Signed by: moritz
GPG key ID: 1020A035E5DD0824
5 changed files with 106 additions and 164 deletions

View file

@ -112,14 +112,16 @@ defmodule Mv.Membership.Member do
# but in test environment it runs synchronously for DB sandbox compatibility
change after_action(fn _changeset, member, _context ->
if member.membership_fee_type_id && member.join_date do
generate_fn = fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _cycles, _notifications} ->
# Notifications are sent automatically by CycleGenerator
:ok
{:ok, _cycles} ->
:ok
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
# Return notifications to Ash so they are sent after commit
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
skip_lock?: false
) do
{:ok, _cycles, notifications} ->
{:ok, member, notifications}
{:error, reason} ->
require Logger
@ -127,19 +129,35 @@ defmodule Mv.Membership.Member do
Logger.warning(
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
generate_fn.()
{:ok, member}
end
else
# Run asynchronously in other environments
Task.start(generate_fn)
end
end
# Notifications cannot be returned in async case, so they will be lost
# This is acceptable as cycle generation is not critical for member creation
Task.start(fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _cycles, notifications} ->
# Send notifications manually for async case
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
end
{:ok, member}
{:error, reason} ->
require Logger
Logger.warning(
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end)
{:ok, member}
end
else
{:ok, member}
end
end)
end
@ -197,7 +215,7 @@ defmodule Mv.Membership.Member do
# This deletes future unpaid cycles and regenerates them with the new type/amount
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
# Notifications are collected and sent after transaction commits
# Notifications are returned to Ash and sent automatically after commit
change after_action(fn changeset, member, _context ->
fee_type_changed =
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
@ -205,13 +223,8 @@ defmodule Mv.Membership.Member do
if fee_type_changed && member.membership_fee_type_id && member.join_date do
case regenerate_cycles_on_type_change(member) do
{:ok, notifications} ->
# Store notifications to be sent after transaction commits
# They will be sent by Ash automatically after commit
if Enum.any?(notifications) do
# Note: We cannot send notifications here as we're still in transaction
# Store them in the changeset context to be sent after commit
:ok
end
# Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications}
{:error, reason} ->
require Logger
@ -219,10 +232,12 @@ defmodule Mv.Membership.Member do
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end
{:ok, member}
{:ok, member}
end
else
{:ok, member}
end
end)
end
@ -732,40 +747,33 @@ defmodule Mv.Membership.Member do
end
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} where notifications should be sent after commit
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end
# Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
# Store notifications to send after commit
# Return notifications - they will be sent by the caller
notifications
{:error, reason} ->
Mv.Repo.rollback(reason)
end
end)
|> handle_transaction_result_with_notifications()
end
# Handle transaction result with notifications
defp handle_transaction_result_with_notifications({:ok, notifications}) do
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
|> case do
{:ok, notifications} -> {:ok, notifications}
{:error, reason} -> {:error, reason}
end
{:ok, []}
end
defp handle_transaction_result_with_notifications({:error, reason}), do: {:error, reason}
# Performs the actual cycle deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
# notifications are collected to be sent after transaction commits
@ -843,7 +851,7 @@ defmodule Mv.Membership.Member do
# Regenerates cycles with new type/amount
# Passes today to ensure consistent date across deletion and regeneration
# skip_lock?: true means advisory lock is already set by caller
# Returns {:ok, notifications} where notifications should be sent after commit
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
@ -853,13 +861,8 @@ defmodule Mv.Membership.Member do
skip_lock?: skip_lock?
) do
{:ok, _cycles, notifications} when is_list(notifications) ->
# When skip_lock? is true and in transaction, notifications are returned
{:ok, notifications}
{:ok, _cycles} ->
# When not in transaction or notifications handled automatically
{:ok, []}
{:error, reason} ->
{:error, reason}
end