Use SystemActor opts for cycle deletion operations
Pass actor_opts to delete_cycles/1 to ensure proper authorization when MembershipFeeCycle policies are enforced
This commit is contained in:
parent
4537b86a09
commit
fa3c34450d
1 changed files with 59 additions and 25 deletions
|
|
@ -124,8 +124,9 @@ defmodule Mv.Membership.Member do
|
|||
case result do
|
||||
{:ok, member} ->
|
||||
if member.membership_fee_type_id && member.join_date do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
handle_cycle_generation(member, [])
|
||||
# Capture initiator for audit trail (if available)
|
||||
initiator = Map.get(changeset.context, :actor)
|
||||
handle_cycle_generation(member, initiator: initiator)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
|
|
@ -228,8 +229,9 @@ defmodule Mv.Membership.Member do
|
|||
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
|
||||
|
||||
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
handle_cycle_generation(member, [])
|
||||
# Capture initiator for audit trail (if available)
|
||||
initiator = Map.get(changeset.context, :actor)
|
||||
handle_cycle_generation(member, initiator: initiator)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
|
|
@ -859,7 +861,13 @@ defmodule Mv.Membership.Member do
|
|||
{:ok, all_unpaid_cycles} ->
|
||||
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
||||
|
||||
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
|
||||
delete_and_regenerate_cycles(
|
||||
cycles_to_delete,
|
||||
member.id,
|
||||
today,
|
||||
actor_opts,
|
||||
skip_lock?: skip_lock?
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
|
@ -886,15 +894,15 @@ defmodule Mv.Membership.Member do
|
|||
# Deletes future cycles and regenerates them with the new type/amount
|
||||
# Passes today to ensure consistent date across deletion and regeneration
|
||||
# Returns {:ok, notifications} or {:error, reason}
|
||||
# Uses system actor for cycle generation
|
||||
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
|
||||
# Uses system actor for cycle generation and deletion
|
||||
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, actor_opts, opts) do
|
||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||
|
||||
if Enum.empty?(cycles_to_delete) do
|
||||
# No cycles to delete, just regenerate
|
||||
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||
else
|
||||
case delete_cycles(cycles_to_delete) do
|
||||
case delete_cycles(cycles_to_delete, actor_opts) do
|
||||
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
|
@ -902,10 +910,11 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
||||
defp delete_cycles(cycles_to_delete) do
|
||||
# Uses system actor for authorization to ensure deletion always works
|
||||
defp delete_cycles(cycles_to_delete, actor_opts) do
|
||||
delete_results =
|
||||
Enum.map(cycles_to_delete, fn cycle ->
|
||||
Ash.destroy(cycle)
|
||||
Ash.destroy(cycle, actor_opts)
|
||||
end)
|
||||
|
||||
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||||
|
|
@ -940,43 +949,58 @@ defmodule Mv.Membership.Member do
|
|||
# This function encapsulates the common logic for cycle generation
|
||||
# to avoid code duplication across different hooks
|
||||
# Uses system actor for cycle generation (mandatory side effect)
|
||||
defp handle_cycle_generation(member, _opts) do
|
||||
# Captures initiator for audit trail (if available in opts)
|
||||
defp handle_cycle_generation(member, opts) do
|
||||
initiator = Keyword.get(opts, :initiator)
|
||||
|
||||
if Mv.Config.sql_sandbox?() do
|
||||
handle_cycle_generation_sync(member)
|
||||
handle_cycle_generation_sync(member, initiator)
|
||||
else
|
||||
handle_cycle_generation_async(member)
|
||||
handle_cycle_generation_async(member, initiator)
|
||||
end
|
||||
end
|
||||
|
||||
# Runs cycle generation synchronously (for test environment)
|
||||
defp handle_cycle_generation_sync(member) do
|
||||
defp handle_cycle_generation_sync(member, initiator) do
|
||||
require Logger
|
||||
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member.id,
|
||||
today: Date.utc_today()
|
||||
today: Date.utc_today(),
|
||||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
log_cycle_generation_success(member, cycles, notifications, sync: true)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: true,
|
||||
initiator: initiator
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
log_cycle_generation_error(member, reason, sync: true)
|
||||
log_cycle_generation_error(member, reason, sync: true, initiator: initiator)
|
||||
end
|
||||
end
|
||||
|
||||
# Runs cycle generation asynchronously (for production environment)
|
||||
defp handle_cycle_generation_async(member) do
|
||||
defp handle_cycle_generation_async(member, initiator) do
|
||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
|
||||
initiator: initiator
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
log_cycle_generation_success(member, cycles, notifications, sync: false)
|
||||
|
||||
log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: false,
|
||||
initiator: initiator
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
log_cycle_generation_error(member, reason, sync: false)
|
||||
log_cycle_generation_error(member, reason, sync: false, initiator: initiator)
|
||||
end
|
||||
end)
|
||||
|> Task.await(:infinity)
|
||||
end
|
||||
|
||||
# Sends notifications if any are present
|
||||
|
|
@ -987,13 +1011,17 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
# Logs successful cycle generation
|
||||
defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do
|
||||
defp log_cycle_generation_success(member, cycles, notifications,
|
||||
sync: sync?,
|
||||
initiator: initiator
|
||||
) do
|
||||
require Logger
|
||||
|
||||
sync_label = if sync?, do: "", else: " (async)"
|
||||
initiator_info = get_initiator_info(initiator)
|
||||
|
||||
Logger.debug(
|
||||
"Successfully generated cycles for member#{sync_label}",
|
||||
"Successfully generated cycles for member#{sync_label} (initiator: #{initiator_info})",
|
||||
member_id: member.id,
|
||||
cycles_count: length(cycles),
|
||||
notifications_count: length(notifications)
|
||||
|
|
@ -1001,13 +1029,14 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
# Logs cycle generation errors
|
||||
defp log_cycle_generation_error(member, reason, sync: sync?) do
|
||||
defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do
|
||||
require Logger
|
||||
|
||||
sync_label = if sync?, do: "", else: " (async)"
|
||||
initiator_info = get_initiator_info(initiator)
|
||||
|
||||
Logger.error(
|
||||
"Failed to generate cycles for member#{sync_label}",
|
||||
"Failed to generate cycles for member#{sync_label} (initiator: #{initiator_info})",
|
||||
member_id: member.id,
|
||||
member_email: member.email,
|
||||
error: inspect(reason),
|
||||
|
|
@ -1015,6 +1044,11 @@ defmodule Mv.Membership.Member do
|
|||
)
|
||||
end
|
||||
|
||||
# Extracts initiator information for audit trail
|
||||
defp get_initiator_info(nil), do: "system"
|
||||
defp get_initiator_info(%{email: email}), do: email
|
||||
defp get_initiator_info(_), do: "unknown"
|
||||
|
||||
# Helper to extract error type for structured logging
|
||||
defp error_type(%{__struct__: struct_name}), do: struct_name
|
||||
defp error_type(error) when is_atom(error), do: error
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue