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