System Actor Mode for Systemic Flows closes #348 #361
16 changed files with 1106 additions and 763 deletions
|
|
@ -641,7 +641,54 @@ def card(assigns) do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 Ash Framework
|
### 3.3 System Actor Pattern
|
||||||
|
|
||||||
|
**When to Use System Actor:**
|
||||||
|
|
||||||
|
Some operations must always run regardless of user permissions. These are **systemic operations** that are mandatory side effects:
|
||||||
|
|
||||||
|
- **Email synchronization** (Member ↔ User)
|
||||||
|
- **Email uniqueness validation** (data integrity requirement)
|
||||||
|
- **Cycle generation** (if defined as mandatory side effect)
|
||||||
|
- **Background jobs**
|
||||||
|
- **Seeds**
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
Use `Mv.Helpers.SystemActor.get_system_actor/0` for all systemic operations:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# Good - Email sync uses system actor
|
||||||
|
def get_linked_member(user) do
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
|
case Ash.get(Mv.Membership.Member, id, opts) do
|
||||||
|
{:ok, member} -> member
|
||||||
|
{:error, _} -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Bad - Using user actor for systemic operation
|
||||||
|
def get_linked_member(user, actor) do
|
||||||
|
opts = Helpers.ash_actor_opts(actor) # May fail if user lacks permissions!
|
||||||
|
# ...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**System Actor Details:**
|
||||||
|
|
||||||
|
- System actor is a user with admin role (email: "system@mila.local")
|
||||||
|
- Cached in Agent for performance
|
||||||
|
- Falls back to admin user from seeds if system user doesn't exist
|
||||||
|
- Should NEVER be used for user-initiated actions (only systemic operations)
|
||||||
|
|
||||||
|
**User Mode vs System Mode:**
|
||||||
|
|
||||||
|
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
|
||||||
|
- **System Mode**: Systemic operations use system actor, bypass user permissions
|
||||||
|
|
||||||
|
### 3.4 Ash Framework
|
||||||
|
|
||||||
**Resource Definition Best Practices:**
|
**Resource Definition Best Practices:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, actor: actor)
|
initiator = Map.get(changeset.context, :actor)
|
||||||
|
handle_cycle_generation(member, initiator: initiator)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|
@ -196,16 +197,12 @@ defmodule Mv.Membership.Member do
|
||||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||||
|
|
||||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
||||||
actor = Map.get(changeset.context, :actor)
|
case regenerate_cycles_on_type_change(member) do
|
||||||
|
|
||||||
case regenerate_cycles_on_type_change(member, actor: actor) do
|
|
||||||
{:ok, notifications} ->
|
{:ok, notifications} ->
|
||||||
# Return notifications to Ash - they will be sent automatically after commit
|
# Return notifications to Ash - they will be sent automatically after commit
|
||||||
{:ok, member, notifications}
|
{:ok, member, notifications}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
require Logger
|
|
||||||
|
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
|
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
|
||||||
)
|
)
|
||||||
|
|
@ -230,8 +227,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, actor: actor)
|
initiator = Map.get(changeset.context, :actor)
|
||||||
|
handle_cycle_generation(member, initiator: initiator)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|
@ -790,37 +788,37 @@ defmodule Mv.Membership.Member do
|
||||||
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
||||||
# to be sent after transaction commits
|
# to be sent after transaction commits
|
||||||
@doc false
|
@doc false
|
||||||
def regenerate_cycles_on_type_change(member, opts \\ []) do
|
# Uses system actor for cycle regeneration (mandatory side effect)
|
||||||
|
def regenerate_cycles_on_type_change(member, _opts \\ []) do
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
lock_key = :erlang.phash2(member.id)
|
lock_key = :erlang.phash2(member.id)
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||||
# This ensures atomicity when multiple updates happen simultaneously
|
# This ensures atomicity when multiple updates happen simultaneously
|
||||||
if Mv.Repo.in_transaction?() do
|
if Mv.Repo.in_transaction?() do
|
||||||
regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
|
regenerate_cycles_in_transaction(member, today, lock_key)
|
||||||
else
|
else
|
||||||
regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
|
regenerate_cycles_new_transaction(member, today, lock_key)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Already in transaction: use advisory lock directly
|
# Already in transaction: use advisory lock directly
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
|
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
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, actor: actor)
|
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Not in transaction: start new transaction with advisory lock
|
# Not in transaction: start new transaction with advisory lock
|
||||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||||
defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
|
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
Mv.Repo.transaction(fn ->
|
Mv.Repo.transaction(fn ->
|
||||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
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, actor: actor) do
|
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||||
{:ok, notifications} ->
|
{:ok, notifications} ->
|
||||||
# Return notifications - they will be sent by the caller
|
# Return notifications - they will be sent by the caller
|
||||||
notifications
|
notifications
|
||||||
|
|
@ -838,11 +836,16 @@ defmodule Mv.Membership.Member do
|
||||||
# Performs the actual cycle deletion and regeneration
|
# Performs the actual cycle deletion and regeneration
|
||||||
# Returns {:ok, notifications} or {:error, reason}
|
# Returns {:ok, notifications} or {:error, reason}
|
||||||
# notifications are collected to be sent after transaction commits
|
# notifications are collected to be sent after transaction commits
|
||||||
|
# Uses system actor for all operations
|
||||||
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
actor = Keyword.get(opts, :actor)
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
actor_opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
# Find all unpaid cycles for this member
|
# Find all unpaid cycles for this member
|
||||||
# We need to check cycle_end for each cycle using its own interval
|
# We need to check cycle_end for each cycle using its own interval
|
||||||
|
|
@ -852,20 +855,16 @@ defmodule Mv.Membership.Member do
|
||||||
|> Ash.Query.filter(status == :unpaid)
|
|> Ash.Query.filter(status == :unpaid)
|
||||||
|> Ash.Query.load([:membership_fee_type])
|
|> Ash.Query.load([:membership_fee_type])
|
||||||
|
|
||||||
result =
|
case Ash.read(all_unpaid_cycles_query, actor_opts) do
|
||||||
if actor do
|
|
||||||
Ash.read(all_unpaid_cycles_query, actor: actor)
|
|
||||||
else
|
|
||||||
Ash.read(all_unpaid_cycles_query)
|
|
||||||
end
|
|
||||||
|
|
||||||
case result 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,
|
delete_and_regenerate_cycles(
|
||||||
skip_lock?: skip_lock?,
|
cycles_to_delete,
|
||||||
actor: actor
|
member.id,
|
||||||
|
today,
|
||||||
|
actor_opts,
|
||||||
|
skip_lock?: skip_lock?
|
||||||
)
|
)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|
@ -893,26 +892,27 @@ 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}
|
||||||
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)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
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?, actor: actor)
|
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?, actor: actor)
|
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
@ -928,13 +928,11 @@ defmodule Mv.Membership.Member do
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
defp regenerate_cycles(member_id, today, opts) do
|
defp regenerate_cycles(member_id, today, opts) do
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||||
member_id,
|
member_id,
|
||||||
today: today,
|
today: today,
|
||||||
skip_lock?: skip_lock?,
|
skip_lock?: skip_lock?
|
||||||
actor: actor
|
|
||||||
) do
|
) do
|
||||||
{:ok, _cycles, notifications} when is_list(notifications) ->
|
{:ok, _cycles, notifications} when is_list(notifications) ->
|
||||||
{:ok, notifications}
|
{:ok, notifications}
|
||||||
|
|
@ -948,49 +946,57 @@ defmodule Mv.Membership.Member do
|
||||||
# based on environment (test vs production)
|
# based on environment (test vs production)
|
||||||
# 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)
|
||||||
|
# Captures initiator for audit trail (if available in opts)
|
||||||
defp handle_cycle_generation(member, opts) do
|
defp handle_cycle_generation(member, opts) do
|
||||||
actor = Keyword.get(opts, :actor)
|
initiator = Keyword.get(opts, :initiator)
|
||||||
|
|
||||||
if Mv.Config.sql_sandbox?() do
|
if Mv.Config.sql_sandbox?() do
|
||||||
handle_cycle_generation_sync(member, actor: actor)
|
handle_cycle_generation_sync(member, initiator)
|
||||||
else
|
else
|
||||||
handle_cycle_generation_async(member, actor: actor)
|
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, opts) do
|
defp handle_cycle_generation_sync(member, initiator) do
|
||||||
require Logger
|
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
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(),
|
||||||
actor: actor
|
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, opts) do
|
defp handle_cycle_generation_async(member, initiator) do
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) 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
|
||||||
|
|
@ -1001,13 +1007,15 @@ 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,
|
||||||
require Logger
|
sync: sync?,
|
||||||
|
initiator: initiator
|
||||||
|
) do
|
||||||
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)
|
||||||
|
|
@ -1015,13 +1023,12 @@ 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
|
|
||||||
|
|
||||||
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),
|
||||||
|
|
@ -1029,6 +1036,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
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Validation
|
use Ash.Resource.Validation
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Validates email uniqueness across linked User-Member pairs.
|
Validates email uniqueness across linked User-Member pairs.
|
||||||
|
|
||||||
|
|
@ -73,19 +75,29 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_email_uniqueness(email, exclude_member_id) do
|
defp check_email_uniqueness(email, exclude_member_id) do
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Query.filter(email == ^to_string(email))
|
|> Ash.Query.filter(email == ^to_string(email))
|
||||||
|> maybe_exclude_id(exclude_member_id)
|
|> maybe_exclude_id(exclude_member_id)
|
||||||
|
|
||||||
case Ash.read(query) do
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:error, field: :email, message: "is already used by another member", value: email}
|
{:error, field: :email, message: "is already used by another member", value: email}
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Email uniqueness validation query failed for user email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
|
||||||
|
)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule Mv.Application do
|
||||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: Mv.PubSub},
|
{Phoenix.PubSub, name: Mv.PubSub},
|
||||||
{AshAuthentication.Supervisor, otp_app: :my},
|
{AshAuthentication.Supervisor, otp_app: :my},
|
||||||
|
Mv.Helpers.SystemActor,
|
||||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||||
# {Mv.Worker, arg},
|
# {Mv.Worker, arg},
|
||||||
# Start to serve requests, typically the last entry
|
# Start to serve requests, typically the last entry
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,8 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
||||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||||
result = callback.(cs)
|
result = callback.(cs)
|
||||||
|
|
||||||
actor = Map.get(changeset.context, :actor)
|
|
||||||
|
|
||||||
with {:ok, member} <- Helpers.extract_record(result),
|
with {:ok, member} <- Helpers.extract_record(result),
|
||||||
linked_user <- Loader.get_linked_user(member, actor) do
|
linked_user <- Loader.get_linked_user(member) do
|
||||||
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
|
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
|
||||||
else
|
else
|
||||||
_ -> result
|
_ -> result
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
if Map.get(context, :syncing_email, false) do
|
if Map.get(context, :syncing_email, false) do
|
||||||
changeset
|
changeset
|
||||||
else
|
else
|
||||||
# Ensure actor is in changeset context - get it from context if available
|
sync_email(changeset)
|
||||||
actor = Map.get(changeset.context, :actor) || Map.get(context, :actor)
|
|
||||||
|
|
||||||
changeset_with_actor =
|
|
||||||
if actor && !Map.has_key?(changeset.context, :actor) do
|
|
||||||
Ash.Changeset.put_context(changeset, :actor, actor)
|
|
||||||
else
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
|
|
||||||
sync_email(changeset_with_actor)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -52,7 +42,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
result = callback.(cs)
|
result = callback.(cs)
|
||||||
|
|
||||||
with {:ok, record} <- Helpers.extract_record(result),
|
with {:ok, record} <- Helpers.extract_record(result),
|
||||||
{:ok, user, member} <- get_user_and_member(record, cs) do
|
{: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 Member-side, we need to update the member in the result
|
||||||
# When called from User-side, we update the linked member in DB only
|
# When called from User-side, we update the linked member in DB only
|
||||||
case record do
|
case record do
|
||||||
|
|
@ -71,19 +61,16 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Retrieves user and member - works for both resource types
|
# Retrieves user and member - works for both resource types
|
||||||
defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
|
# Uses system actor via Loader functions
|
||||||
actor = Map.get(changeset.context, :actor)
|
defp get_user_and_member(%Mv.Accounts.User{} = user) do
|
||||||
|
case Loader.get_linked_member(user) do
|
||||||
case Loader.get_linked_member(user, actor) do
|
|
||||||
nil -> {:error, :no_member}
|
nil -> {:error, :no_member}
|
||||||
member -> {:ok, user, member}
|
member -> {:ok, user, member}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
|
defp get_user_and_member(%Mv.Membership.Member{} = member) do
|
||||||
actor = Map.get(changeset.context, :actor)
|
case Loader.load_linked_user!(member) do
|
||||||
|
|
||||||
case Loader.load_linked_user!(member, actor) do
|
|
||||||
{:ok, user} -> {:ok, user, member}
|
{:ok, user} -> {:ok, user, member}
|
||||||
error -> error
|
error -> error
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,26 @@ defmodule Mv.EmailSync.Loader do
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
This module runs systemically and accepts optional actor parameters.
|
This module runs systemically and uses the system actor for all operations.
|
||||||
When called from hooks/changes, actor is extracted from changeset context.
|
This ensures that email synchronization always works, regardless of user permissions.
|
||||||
When called directly, actor should be provided for proper authorization.
|
|
||||||
|
|
||||||
All functions accept an optional `actor` parameter that is passed to Ash operations
|
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
|
||||||
to ensure proper authorization checks are performed.
|
user permission checks, as email sync is a mandatory side effect.
|
||||||
"""
|
"""
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Loads the member linked to a user, returns nil if not linked or on error.
|
Loads the member linked to a user, returns nil if not linked or on error.
|
||||||
|
|
||||||
Accepts optional actor for authorization.
|
Uses system actor for authorization to ensure email sync always works.
|
||||||
"""
|
"""
|
||||||
def get_linked_member(user, actor \\ nil)
|
def get_linked_member(user)
|
||||||
def get_linked_member(%{member_id: nil}, _actor), do: nil
|
def get_linked_member(%{member_id: nil}), do: nil
|
||||||
|
|
||||||
def get_linked_member(%{member_id: id}, actor) do
|
def get_linked_member(%{member_id: id}) do
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
case Ash.get(Mv.Membership.Member, id, opts) do
|
case Ash.get(Mv.Membership.Member, id, opts) do
|
||||||
{:ok, member} -> member
|
{:ok, member} -> member
|
||||||
|
|
@ -34,10 +35,11 @@ defmodule Mv.EmailSync.Loader do
|
||||||
@doc """
|
@doc """
|
||||||
Loads the user linked to a member, returns nil if not linked or on error.
|
Loads the user linked to a member, returns nil if not linked or on error.
|
||||||
|
|
||||||
Accepts optional actor for authorization.
|
Uses system actor for authorization to ensure email sync always works.
|
||||||
"""
|
"""
|
||||||
def get_linked_user(member, actor \\ nil) do
|
def get_linked_user(member) do
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
case Ash.load(member, :user, opts) do
|
case Ash.load(member, :user, opts) do
|
||||||
{:ok, %{user: user}} -> user
|
{:ok, %{user: user}} -> user
|
||||||
|
|
@ -49,10 +51,11 @@ defmodule Mv.EmailSync.Loader do
|
||||||
Loads the user linked to a member, returning an error tuple if not linked.
|
Loads the user linked to a member, returning an error tuple if not linked.
|
||||||
Useful when a link is required for the operation.
|
Useful when a link is required for the operation.
|
||||||
|
|
||||||
Accepts optional actor for authorization.
|
Uses system actor for authorization to ensure email sync always works.
|
||||||
"""
|
"""
|
||||||
def load_linked_user!(member, actor \\ nil) do
|
def load_linked_user!(member) do
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
case Ash.load(member, :user, opts) do
|
case Ash.load(member, :user, opts) do
|
||||||
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
|
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
|
||||||
|
|
|
||||||
436
lib/mv/helpers/system_actor.ex
Normal file
436
lib/mv/helpers/system_actor.ex
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
defmodule Mv.Helpers.SystemActor do
|
||||||
|
@moduledoc """
|
||||||
|
Provides access to the system actor for systemic operations.
|
||||||
|
|
||||||
|
The system actor is a user with admin permissions that is used
|
||||||
|
for operations that must always run regardless of user permissions:
|
||||||
|
- Email synchronization
|
||||||
|
- Email uniqueness validation
|
||||||
|
- Cycle generation (if mandatory)
|
||||||
|
- Background jobs
|
||||||
|
- Seeds
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
# Get system actor for systemic operations
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
Ash.read(query, actor: system_actor)
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
The system actor is cached in an Agent for performance. On first access,
|
||||||
|
it attempts to load a user with email "system@mila.local" and admin role.
|
||||||
|
If that user doesn't exist, it falls back to the admin user from seeds
|
||||||
|
(identified by ADMIN_EMAIL environment variable or "admin@localhost").
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
The system actor is cached in an Agent to avoid repeated database queries.
|
||||||
|
The cache is invalidated on application restart. For long-running applications,
|
||||||
|
consider implementing cache invalidation on role changes.
|
||||||
|
|
||||||
|
## Race Conditions
|
||||||
|
|
||||||
|
The system actor creation uses `upsert?: true` with `upsert_identity: :unique_email`
|
||||||
|
to prevent race conditions when multiple processes try to create the system user
|
||||||
|
simultaneously. This ensures idempotent creation and prevents database constraint errors.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The system actor should NEVER be used for user-initiated actions. It is
|
||||||
|
only for systemic operations that must bypass user permissions.
|
||||||
|
|
||||||
|
The system user is created without a password (`hashed_password = nil`) and
|
||||||
|
without an OIDC ID (`oidc_id = nil`) to prevent login. This ensures the
|
||||||
|
system user cannot be used for authentication, even if credentials are
|
||||||
|
somehow obtained.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Agent
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the SystemActor Agent.
|
||||||
|
|
||||||
|
This is called automatically by the application supervisor.
|
||||||
|
The agent starts with nil state and loads the system actor lazily on first access.
|
||||||
|
"""
|
||||||
|
def start_link(_opts) do
|
||||||
|
# Start with nil - lazy initialization on first get_system_actor call
|
||||||
|
# This prevents database access during application startup (important for tests)
|
||||||
|
Agent.start_link(fn -> nil end, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the system actor (user with admin role).
|
||||||
|
|
||||||
|
The system actor is cached in an Agent for performance. On first access,
|
||||||
|
it loads the system user from the database or falls back to the admin user.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `%Mv.Accounts.User{}` - User with admin role loaded
|
||||||
|
- Raises if system actor cannot be found or loaded
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
iex> system_actor.role.permission_set_name
|
||||||
|
"admin"
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec get_system_actor() :: Mv.Accounts.User.t()
|
||||||
|
def get_system_actor do
|
||||||
|
case get_system_actor_result() do
|
||||||
|
{:ok, actor} -> actor
|
||||||
|
{:error, reason} -> raise "Failed to load system actor: #{inspect(reason)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the system actor as a result tuple.
|
||||||
|
|
||||||
|
This variant returns `{:ok, actor}` or `{:error, reason}` instead of raising,
|
||||||
|
which is useful for error handling in pipes or when you want to handle errors explicitly.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `{:ok, %Mv.Accounts.User{}}` - Successfully loaded system actor
|
||||||
|
- `{:error, term()}` - Error loading system actor
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
case SystemActor.get_system_actor_result() do
|
||||||
|
{:ok, actor} -> use_actor(actor)
|
||||||
|
{:error, reason} -> handle_error(reason)
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec get_system_actor_result() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
|
||||||
|
def get_system_actor_result do
|
||||||
|
# In test environment (SQL sandbox), always load directly to avoid Agent/Sandbox issues
|
||||||
|
if Config.sql_sandbox?() do
|
||||||
|
try do
|
||||||
|
{:ok, load_system_actor()}
|
||||||
|
rescue
|
||||||
|
e -> {:error, e}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
try do
|
||||||
|
result =
|
||||||
|
Agent.get_and_update(__MODULE__, fn
|
||||||
|
nil ->
|
||||||
|
# Cache miss - load system actor
|
||||||
|
try do
|
||||||
|
actor = load_system_actor()
|
||||||
|
{actor, actor}
|
||||||
|
rescue
|
||||||
|
e -> {{:error, e}, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
cached_actor ->
|
||||||
|
# Cache hit - return cached actor
|
||||||
|
{cached_actor, cached_actor}
|
||||||
|
end)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
actor -> {:ok, actor}
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
:exit, {:noproc, _} ->
|
||||||
|
# Agent not started - load directly without caching
|
||||||
|
try do
|
||||||
|
{:ok, load_system_actor()}
|
||||||
|
rescue
|
||||||
|
e -> {:error, e}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Invalidates the system actor cache.
|
||||||
|
|
||||||
|
This forces a reload of the system actor on the next call to `get_system_actor/0`.
|
||||||
|
Useful when the system user's role might have changed.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Mv.Helpers.SystemActor.invalidate_cache()
|
||||||
|
:ok
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec invalidate_cache() :: :ok
|
||||||
|
def invalidate_cache do
|
||||||
|
case Process.whereis(__MODULE__) do
|
||||||
|
nil -> :ok
|
||||||
|
_pid -> Agent.update(__MODULE__, fn _state -> nil end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the email address of the system user.
|
||||||
|
|
||||||
|
This is useful for other modules that need to reference the system user
|
||||||
|
without loading the full user record.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `String.t()` - The system user email address ("system@mila.local")
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Mv.Helpers.SystemActor.system_user_email()
|
||||||
|
"system@mila.local"
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec system_user_email() :: String.t()
|
||||||
|
def system_user_email, do: system_user_email_config()
|
||||||
|
|
||||||
|
# Returns the system user email from environment variable or default
|
||||||
|
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
||||||
|
@spec system_user_email_config() :: String.t()
|
||||||
|
defp system_user_email_config do
|
||||||
|
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Loads the system actor from the database
|
||||||
|
# First tries to find system@mila.local, then falls back to admin user
|
||||||
|
@spec load_system_actor() :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp load_system_actor do
|
||||||
|
case find_user_by_email(system_user_email_config()) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
load_user_with_role(user)
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
handle_system_user_not_found("no system user or admin user found")
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
handle_system_user_error(error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles case when system user doesn't exist
|
||||||
|
@spec handle_system_user_not_found(String.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp handle_system_user_not_found(message) do
|
||||||
|
case load_admin_user_fallback() do
|
||||||
|
{:ok, admin_user} ->
|
||||||
|
admin_user
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
handle_fallback_error(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles database error when loading system user
|
||||||
|
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp handle_system_user_error(error) do
|
||||||
|
case load_admin_user_fallback() do
|
||||||
|
{:ok, admin_user} ->
|
||||||
|
admin_user
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
handle_fallback_error("Failed to load system actor: #{inspect(error)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles fallback error - creates test actor or raises
|
||||||
|
@spec handle_fallback_error(String.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp handle_fallback_error(message) do
|
||||||
|
if Config.sql_sandbox?() do
|
||||||
|
create_test_system_actor()
|
||||||
|
else
|
||||||
|
raise "Failed to load system actor: #{message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Creates a temporary admin user for tests when no system/admin user exists
|
||||||
|
@spec create_test_system_actor() :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp create_test_system_actor do
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
admin_role = ensure_admin_role_exists()
|
||||||
|
create_system_user_with_role(admin_role)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensures admin role exists - finds or creates it
|
||||||
|
@spec ensure_admin_role_exists() :: Mv.Authorization.Role.t() | no_return()
|
||||||
|
defp ensure_admin_role_exists do
|
||||||
|
case find_admin_role() do
|
||||||
|
{:ok, role} ->
|
||||||
|
role
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
create_admin_role_with_retry()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds admin role in existing roles
|
||||||
|
@spec find_admin_role() :: {:ok, Mv.Authorization.Role.t()} | {:error, :not_found}
|
||||||
|
defp find_admin_role do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
case Authorization.list_roles() do
|
||||||
|
{:ok, roles} ->
|
||||||
|
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
role -> {:ok, role}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Creates admin role, handling race conditions
|
||||||
|
@spec create_admin_role_with_retry() :: Mv.Authorization.Role.t() | no_return()
|
||||||
|
defp create_admin_role_with_retry do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
case create_admin_role() do
|
||||||
|
{:ok, role} ->
|
||||||
|
role
|
||||||
|
|
||||||
|
{:error, :already_exists} ->
|
||||||
|
find_existing_admin_role()
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
raise "Failed to create admin role: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Attempts to create admin role
|
||||||
|
@spec create_admin_role() ::
|
||||||
|
{:ok, Mv.Authorization.Role.t()} | {:error, :already_exists | term()}
|
||||||
|
defp create_admin_role do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
case Authorization.create_role(%{
|
||||||
|
name: "Admin",
|
||||||
|
description: "Administrator with full access",
|
||||||
|
permission_set_name: "admin"
|
||||||
|
}) do
|
||||||
|
{:ok, role} ->
|
||||||
|
{:ok, role}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Invalid{errors: [%{field: :name, message: "has already been taken"}]}} ->
|
||||||
|
{:error, :already_exists}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds existing admin role after creation attempt failed due to race condition
|
||||||
|
@spec find_existing_admin_role() :: Mv.Authorization.Role.t() | no_return()
|
||||||
|
defp find_existing_admin_role do
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
case Authorization.list_roles() do
|
||||||
|
{:ok, roles} ->
|
||||||
|
Enum.find(roles, &(&1.permission_set_name == "admin")) ||
|
||||||
|
raise "Admin role should exist but was not found"
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
raise "Failed to find admin role after creation attempt"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Creates system user with admin role assigned
|
||||||
|
# SECURITY: System user is created without password (hashed_password = nil) and
|
||||||
|
# without OIDC ID (oidc_id = nil) to prevent login. This user is ONLY for
|
||||||
|
# internal system operations via SystemActor and should never be used for authentication.
|
||||||
|
@spec create_system_user_with_role(Mv.Authorization.Role.t()) ::
|
||||||
|
Mv.Accounts.User.t() | no_return()
|
||||||
|
defp create_system_user_with_role(admin_role) do
|
||||||
|
alias Mv.Accounts
|
||||||
|
|
||||||
|
Accounts.create_user!(%{email: system_user_email_config()},
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email
|
||||||
|
)
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds a user by email address
|
||||||
|
# SECURITY: Uses authorize?: false for bootstrap lookup only.
|
||||||
|
# This is necessary because we need to find the system/admin user before
|
||||||
|
# we can load the system actor. If User policies require an actor, this
|
||||||
|
# would create a chicken-and-egg problem. This is safe because:
|
||||||
|
# 1. We only query by email (no sensitive data exposed)
|
||||||
|
# 2. This is only used during system actor initialization (bootstrap phase)
|
||||||
|
# 3. Once system actor is loaded, all subsequent operations use proper authorization
|
||||||
|
@spec find_user_by_email(String.t()) :: {:ok, Mv.Accounts.User.t() | nil} | {:error, term()}
|
||||||
|
defp find_user_by_email(email) do
|
||||||
|
Mv.Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts, authorize?: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Loads a user with their role preloaded (required for authorization)
|
||||||
|
@spec load_user_with_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp load_user_with_role(user) do
|
||||||
|
case Ash.load(user, :role, domain: Mv.Accounts) do
|
||||||
|
{:ok, user_with_role} ->
|
||||||
|
validate_admin_role(user_with_role)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
raise "Failed to load role for system actor: #{inspect(reason)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validates that the user has an admin role
|
||||||
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: Mv.Accounts.User.t() | no_return()
|
||||||
|
defp validate_admin_role(%{role: %{permission_set_name: "admin"}} = user) do
|
||||||
|
user
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
|
||||||
|
defp validate_admin_role(%{role: %{permission_set_name: permission_set}}) do
|
||||||
|
raise """
|
||||||
|
System actor must have admin role, but has permission_set_name: #{permission_set}
|
||||||
|
Please assign the "Admin" role to the system user.
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec validate_admin_role(Mv.Accounts.User.t()) :: no_return()
|
||||||
|
defp validate_admin_role(%{role: nil}) do
|
||||||
|
raise """
|
||||||
|
System actor must have a role assigned, but role is nil.
|
||||||
|
Please assign the "Admin" role to the system user.
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec validate_admin_role(term()) :: no_return()
|
||||||
|
defp validate_admin_role(_user) do
|
||||||
|
raise """
|
||||||
|
System actor must have a role with admin permissions.
|
||||||
|
Please assign the "Admin" role to the system user.
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fallback: Loads admin user from seeds (ADMIN_EMAIL env var or default)
|
||||||
|
@spec load_admin_user_fallback() :: {:ok, Mv.Accounts.User.t()} | {:error, term()}
|
||||||
|
defp load_admin_user_fallback do
|
||||||
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
case find_user_by_email(admin_email) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
{:ok, load_user_with_role(user)}
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
{:error, :admin_user_not_found}
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -10,6 +10,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
use Ash.Resource.Validation
|
use Ash.Resource.Validation
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Validates email uniqueness across linked Member-User pairs.
|
Validates email uniqueness across linked Member-User pairs.
|
||||||
|
|
||||||
|
|
@ -30,8 +32,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
def validate(changeset, _opts, _context) do
|
def validate(changeset, _opts, _context) do
|
||||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||||
|
|
||||||
actor = Map.get(changeset.context || %{}, :actor)
|
linked_user_id = get_linked_user_id(changeset.data)
|
||||||
linked_user_id = get_linked_user_id(changeset.data, actor)
|
|
||||||
is_linked? = not is_nil(linked_user_id)
|
is_linked? = not is_nil(linked_user_id)
|
||||||
|
|
||||||
# Only validate if member is already linked AND email is changing
|
# Only validate if member is already linked AND email is changing
|
||||||
|
|
@ -40,19 +41,22 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
|
|
||||||
if should_validate? do
|
if should_validate? do
|
||||||
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||||
check_email_uniqueness(new_email, linked_user_id, actor)
|
check_email_uniqueness(new_email, linked_user_id)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_email_uniqueness(email, exclude_user_id, actor) do
|
defp check_email_uniqueness(email, exclude_user_id) do
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(email == ^email)
|
|> Ash.Query.filter(email == ^email)
|
||||||
|> maybe_exclude_id(exclude_user_id)
|
|> maybe_exclude_id(exclude_user_id)
|
||||||
|
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
case Ash.read(query, opts) do
|
case Ash.read(query, opts) do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
|
|
@ -61,7 +65,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:error, field: :email, message: "is already used by another user", value: email}
|
{:error, field: :email, message: "is already used by another user", value: email}
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Email uniqueness validation query failed for member email '#{email}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
|
||||||
|
)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -69,8 +77,11 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
defp maybe_exclude_id(query, nil), do: query
|
defp maybe_exclude_id(query, nil), do: query
|
||||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||||
|
|
||||||
defp get_linked_user_id(member_data, actor) do
|
defp get_linked_user_id(member_data) do
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
case Ash.load(member_data, :user, opts) do
|
case Ash.load(member_data, :user, opts) do
|
||||||
{:ok, %{user: %{id: id}}} -> id
|
{:ok, %{user: %{id: id}}} -> id
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,11 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
This module runs systemically and accepts optional actor parameters.
|
This module runs systemically and uses the system actor for all operations.
|
||||||
When called from hooks/changes, actor is extracted from changeset context.
|
This ensures that cycle generation always works, regardless of user permissions.
|
||||||
When called directly, actor should be provided for proper authorization.
|
|
||||||
|
|
||||||
All functions accept an optional `actor` parameter in the `opts` keyword list
|
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
|
||||||
that is passed to Ash operations to ensure proper authorization checks are performed.
|
user permission checks, as cycle generation is a mandatory side effect.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|
@ -47,6 +46,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||||
|
|
@ -86,9 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
def generate_cycles_for_member(member_or_id, opts \\ [])
|
def generate_cycles_for_member(member_or_id, opts \\ [])
|
||||||
|
|
||||||
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
||||||
actor = Keyword.get(opts, :actor)
|
case load_member(member_id) do
|
||||||
|
|
||||||
case load_member(member_id, actor: actor) do
|
|
||||||
{:ok, member} -> generate_cycles_for_member(member, opts)
|
{:ok, member} -> generate_cycles_for_member(member, opts)
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
|
|
@ -98,27 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
today = Keyword.get(opts, :today, Date.utc_today())
|
today = Keyword.get(opts, :today, Date.utc_today())
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
|
||||||
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
|
do_generate_cycles_with_lock(member, today, skip_lock?)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate cycles with lock handling
|
# Generate cycles with lock handling
|
||||||
# Returns {:ok, cycles, notifications} - notifications are never sent here,
|
# Returns {:ok, cycles, notifications} - notifications are never sent here,
|
||||||
# they should be returned to the caller (e.g., via after_action hook)
|
# they should be returned to the caller (e.g., via after_action hook)
|
||||||
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
|
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
|
||||||
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
|
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
|
||||||
# Just generate cycles without additional locking
|
# Just generate cycles without additional locking
|
||||||
actor = Keyword.get(opts, :actor)
|
do_generate_cycles(member, today)
|
||||||
do_generate_cycles(member, today, actor: actor)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_generate_cycles_with_lock(member, today, false, opts) do
|
defp do_generate_cycles_with_lock(member, today, false) do
|
||||||
lock_key = :erlang.phash2(member.id)
|
lock_key = :erlang.phash2(member.id)
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
case do_generate_cycles(member, today, actor: actor) do
|
case do_generate_cycles(member, today) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
# Return cycles and notifications - do NOT send notifications here
|
# Return cycles and notifications - do NOT send notifications here
|
||||||
# They will be sent by the caller (e.g., via after_action hook)
|
# They will be sent by the caller (e.g., via after_action hook)
|
||||||
|
|
@ -168,12 +165,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
# Query ALL members with fee type assigned (including inactive/left members)
|
# Query ALL members with fee type assigned (including inactive/left members)
|
||||||
# The exit_date boundary is applied during cycle generation, not here.
|
# The exit_date boundary is applied during cycle generation, not here.
|
||||||
# This allows catch-up generation for members who left but are missing cycles.
|
# This allows catch-up generation for members who left but are missing cycles.
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
||||||
|> Ash.Query.filter(not is_nil(join_date))
|
|> Ash.Query.filter(not is_nil(join_date))
|
||||||
|
|
||||||
case Ash.read(query) do
|
case Ash.read(query, opts) do
|
||||||
{:ok, members} ->
|
{:ok, members} ->
|
||||||
results = process_members_in_batches(members, batch_size, today)
|
results = process_members_in_batches(members, batch_size, today)
|
||||||
{:ok, build_results_summary(results)}
|
{:ok, build_results_summary(results)}
|
||||||
|
|
@ -235,33 +235,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
|
|
||||||
# Private functions
|
# Private functions
|
||||||
|
|
||||||
defp load_member(member_id, opts) do
|
defp load_member(member_id) do
|
||||||
actor = Keyword.get(opts, :actor)
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.filter(id == ^member_id)
|
|> Ash.Query.filter(id == ^member_id)
|
||||||
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
||||||
|
|
||||||
result =
|
case Ash.read_one(query, opts) do
|
||||||
if actor do
|
|
||||||
Ash.read_one(query, actor: actor)
|
|
||||||
else
|
|
||||||
Ash.read_one(query)
|
|
||||||
end
|
|
||||||
|
|
||||||
case result do
|
|
||||||
{:ok, nil} -> {:error, :member_not_found}
|
{:ok, nil} -> {:error, :member_not_found}
|
||||||
{:ok, member} -> {:ok, member}
|
{:ok, member} -> {:ok, member}
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_generate_cycles(member, today, opts) do
|
defp do_generate_cycles(member, today) do
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
|
|
||||||
# Reload member with relationships to ensure fresh data
|
# Reload member with relationships to ensure fresh data
|
||||||
case load_member(member.id, actor: actor) do
|
case load_member(member.id) do
|
||||||
{:ok, member} ->
|
{:ok, member} ->
|
||||||
cond do
|
cond do
|
||||||
is_nil(member.membership_fee_type_id) ->
|
is_nil(member.membership_fee_type_id) ->
|
||||||
|
|
@ -271,7 +263,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
{:error, :no_join_date}
|
{:error, :no_join_date}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
generate_missing_cycles(member, today, actor: actor)
|
generate_missing_cycles(member, today)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|
@ -279,8 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_missing_cycles(member, today, opts) do
|
defp generate_missing_cycles(member, today) do
|
||||||
actor = Keyword.get(opts, :actor)
|
|
||||||
fee_type = member.membership_fee_type
|
fee_type = member.membership_fee_type
|
||||||
interval = fee_type.interval
|
interval = fee_type.interval
|
||||||
amount = fee_type.amount
|
amount = fee_type.amount
|
||||||
|
|
@ -296,7 +287,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
# Only generate if start_date <= end_date
|
# Only generate if start_date <= end_date
|
||||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
if start_date && Date.compare(start_date, end_date) != :gt do
|
||||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
||||||
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
|
create_cycles(cycle_starts, member.id, fee_type.id, amount)
|
||||||
else
|
else
|
||||||
{:ok, [], []}
|
{:ok, [], []}
|
||||||
end
|
end
|
||||||
|
|
@ -391,8 +382,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
|
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
|
||||||
actor = Keyword.get(opts, :actor)
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
# Always use return_notifications?: true to collect notifications
|
# Always use return_notifications?: true to collect notifications
|
||||||
# Notifications will be returned to the caller, who is responsible for
|
# Notifications will be returned to the caller, who is responsible for
|
||||||
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
|
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
|
||||||
|
|
@ -407,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_cycle_creation_result(
|
handle_cycle_creation_result(
|
||||||
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
|
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts),
|
||||||
cycle_start
|
cycle_start
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -554,46 +554,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("regenerate_cycles", _params, socket) do
|
def handle_event("regenerate_cycles", _params, socket) do
|
||||||
socket = assign(socket, :regenerating, true)
|
|
||||||
member = socket.assigns.member
|
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
|
# SECURITY: Only admins can manually regenerate cycles via UI
|
||||||
{:ok, _new_cycles, _notifications} ->
|
# Cycle generation itself uses system actor, but UI access should be restricted
|
||||||
# Reload member with cycles
|
if actor.role && actor.role.permission_set_name == "admin" do
|
||||||
actor = current_actor(socket)
|
socket = assign(socket, :regenerating, true)
|
||||||
|
member = socket.assigns.member
|
||||||
|
|
||||||
updated_member =
|
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||||
member
|
{:ok, _new_cycles, _notifications} ->
|
||||||
|> Ash.load!(
|
# Reload member with cycles
|
||||||
[
|
actor = current_actor(socket)
|
||||||
:membership_fee_type,
|
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
|
||||||
],
|
|
||||||
actor: actor
|
|
||||||
)
|
|
||||||
|
|
||||||
cycles =
|
updated_member =
|
||||||
Enum.sort_by(
|
member
|
||||||
updated_member.membership_fee_cycles || [],
|
|> Ash.load!(
|
||||||
& &1.cycle_start,
|
[
|
||||||
{:desc, Date}
|
:membership_fee_type,
|
||||||
)
|
membership_fee_cycles: [:membership_fee_type]
|
||||||
|
],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
send(self(), {:member_updated, updated_member})
|
cycles =
|
||||||
|
Enum.sort_by(
|
||||||
|
updated_member.membership_fee_cycles || [],
|
||||||
|
& &1.cycle_start,
|
||||||
|
{:desc, Date}
|
||||||
|
)
|
||||||
|
|
||||||
{:noreply,
|
send(self(), {:member_updated, updated_member})
|
||||||
socket
|
|
||||||
|> assign(:member, updated_member)
|
|
||||||
|> assign(:cycles, cycles)
|
|
||||||
|> assign(:regenerating, false)
|
|
||||||
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
|
||||||
|
|
||||||
{:error, error} ->
|
{:noreply,
|
||||||
{:noreply,
|
socket
|
||||||
socket
|
|> assign(:member, updated_member)
|
||||||
|> assign(:regenerating, false)
|
|> assign(:cycles, cycles)
|
||||||
|> put_flash(:error, format_error(error))}
|
|> assign(:regenerating, false)
|
||||||
|
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:regenerating, false)
|
||||||
|
|> put_flash(:error, format_error(error))}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1926,289 +1926,7 @@ msgstr "Validierung fehlgeschlagen: %{field} %{message}"
|
||||||
msgid "Validation failed: %{message}"
|
msgid "Validation failed: %{message}"
|
||||||
msgstr "Validierung fehlgeschlagen: %{message}"
|
msgstr "Validierung fehlgeschlagen: %{message}"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Use this form to manage Custom Field Value records in your database."
|
msgid "Only administrators can regenerate cycles"
|
||||||
#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
|
msgstr "Nur Administrator*innen können Zyklen regenerieren"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Member"
|
|
||||||
#~ msgstr "Mitglied"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Choose a custom field"
|
|
||||||
#~ msgstr "Wähle ein Benutzerdefiniertes Feld"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Joining year - reduced to 0"
|
|
||||||
#~ msgstr "Beitrittsjahr – auf 0 reduziert"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Regular"
|
|
||||||
#~ msgstr "Regulär"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Current"
|
|
||||||
#~ msgstr "Aktuell"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Paid via bank transfer"
|
|
||||||
#~ msgstr "Bezahlt durch Überweisung"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Mark as Unpaid"
|
|
||||||
#~ msgstr "Als unbezahlt markieren"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Half-yearly contribution for supporting members"
|
|
||||||
#~ msgstr "Halbjährlicher Beitrag für Fördermitglieder"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Reduced fee for unemployed, pensioners, or low income"
|
|
||||||
#~ msgstr "Ermäßigter Beitrag für Arbeitslose, Rentner*innen oder Geringverdienende"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Custom field value not found"
|
|
||||||
#~ msgstr "Benutzerdefinierter Feldwert nicht gefunden"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Supporting Member"
|
|
||||||
#~ msgstr "Fördermitglied"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Monthly fee for students and trainees"
|
|
||||||
#~ msgstr "Monatlicher Beitrag für Studierende und Auszubildende"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Custom field value %{action} successfully"
|
|
||||||
#~ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Total Contributions"
|
|
||||||
#~ msgstr "Gesamtbeiträge"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Manage contribution types for membership fees."
|
|
||||||
#~ msgstr "Beitragsarten für Mitgliedsbeiträge verwalten."
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Change Contribution Type"
|
|
||||||
#~ msgstr "Beitragsart ändern"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "New Contribution Type"
|
|
||||||
#~ msgstr "Neue Beitragsart"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Time Period"
|
|
||||||
#~ msgstr "Zeitraum"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Custom field value deleted successfully"
|
|
||||||
#~ msgstr "Benutzerdefinierter Feldwert erfolgreich gelöscht"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "You do not have permission to access this custom field value"
|
|
||||||
#~ msgstr "Sie haben keine Berechtigung, auf diesen benutzerdefinierten Feldwert zuzugreifen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Cannot delete - members assigned"
|
|
||||||
#~ msgstr "Löschen nicht möglich – es sind Mitglieder zugewiesen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Preview Mockup"
|
|
||||||
#~ msgstr "Vorschau"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contribution Types"
|
|
||||||
#~ msgstr "Beitragsarten"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "This page is not functional and only displays the planned features."
|
|
||||||
#~ msgstr "Diese Seite ist nicht funktionsfähig und zeigt nur geplante Funktionen."
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Member since"
|
|
||||||
#~ msgstr "Mitglied seit"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Unsupported value type: %{type}"
|
|
||||||
#~ msgstr "Nicht unterstützter Wertetyp: %{type}"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Custom field"
|
|
||||||
#~ msgstr "Benutzerdefinierte Felder"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Mark as Paid"
|
|
||||||
#~ msgstr "Als bezahlt markieren"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contribution type"
|
|
||||||
#~ msgstr "Beitragsart"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contributions"
|
|
||||||
#~ msgstr "Beiträge"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Reduced"
|
|
||||||
#~ msgstr "Reduziert"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "No fee for honorary members"
|
|
||||||
#~ msgstr "Kein Beitrag für ehrenamtliche Mitglieder"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "You do not have permission to delete this custom field value"
|
|
||||||
#~ msgstr "Sie haben keine Berechtigung, diesen benutzerdefinierten Feldwert zu löschen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "%{count} period selected"
|
|
||||||
#~ msgid_plural "%{count} periods selected"
|
|
||||||
#~ msgstr[0] "%{count} Zyklus ausgewählt"
|
|
||||||
#~ msgstr[1] "%{count} Zyklen ausgewählt"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Mark as Suspended"
|
|
||||||
#~ msgstr "Als pausiert markieren"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
|
||||||
#~ msgstr "Beitragsarten definieren verschiedene Beitragsmodelle. Jede Art hat einen festen Zyklus (monatlich, vierteljährlich, halbjährlich, jährlich), der nach Erstellung nicht mehr geändert werden kann."
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Choose a member"
|
|
||||||
#~ msgstr "Mitglied auswählen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Suspend"
|
|
||||||
#~ msgstr "Pausieren"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Reopen"
|
|
||||||
#~ msgstr "Wieder öffnen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Value"
|
|
||||||
#~ msgstr "Wert"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Why are not all contribution types shown?"
|
|
||||||
#~ msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contribution Start"
|
|
||||||
#~ msgstr "Beitragsbeginn"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Standard membership fee for regular members"
|
|
||||||
#~ msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Save Custom Field Value"
|
|
||||||
#~ msgstr "Benutzerdefinierten Feldwert speichern"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Honorary"
|
|
||||||
#~ msgstr "Ehrenamtlich"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contributions for %{name}"
|
|
||||||
#~ msgstr "Beiträge für %{name}"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Family"
|
|
||||||
#~ msgstr "Familie"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "You do not have permission to view custom field values"
|
|
||||||
#~ msgstr "Sie haben keine Berechtigung, benutzerdefinierte Feldwerte anzuzeigen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Student"
|
|
||||||
#~ msgstr "Student"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Quarterly fee for family memberships"
|
|
||||||
#~ msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
|
||||||
#~ msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden."
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Please select a custom field first"
|
|
||||||
#~ msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Open Contributions"
|
|
||||||
#~ msgstr "Offene Beiträge"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Member Contributions"
|
|
||||||
#~ msgstr "Mitgliedsbeiträge"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "About Contribution Types"
|
|
||||||
#~ msgstr "Über Beitragsarten"
|
|
||||||
|
|
|
||||||
|
|
@ -1926,3 +1926,8 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Validation failed: %{message}"
|
msgid "Validation failed: %{message}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Only administrators can regenerate cycles"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -1927,294 +1927,7 @@ msgstr ""
|
||||||
msgid "Validation failed: %{message}"
|
msgid "Validation failed: %{message}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Use this form to manage Custom Field Value records in your database."
|
msgid "Only administrators can regenerate cycles"
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Member"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Choose a custom field"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Joining year - reduced to 0"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Admin"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Regular"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Current"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Paid via bank transfer"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Mark as Unpaid"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Half-yearly contribution for supporting members"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Reduced fee for unemployed, pensioners, or low income"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Custom field value not found"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Supporting Member"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Monthly fee for students and trainees"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Custom field value %{action} successfully"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Total Contributions"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Manage contribution types for membership fees."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Change Contribution Type"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "New Contribution Type"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Time Period"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Custom field value deleted successfully"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "You do not have permission to access this custom field value"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Cannot delete - members assigned"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Preview Mockup"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Contribution Types"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "This page is not functional and only displays the planned features."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Member since"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Unsupported value type: %{type}"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Custom field"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Mark as Paid"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Contribution type"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Contributions"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Reduced"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "No fee for honorary members"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "You do not have permission to delete this custom field value"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "%{count} period selected"
|
|
||||||
#~ msgid_plural "%{count} periods selected"
|
|
||||||
#~ msgstr[0] ""
|
|
||||||
#~ msgstr[1] ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Mark as Suspended"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Choose a member"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Suspend"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Reopen"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Value"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Why are not all contribution types shown?"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Contribution Start"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Standard membership fee for regular members"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Save Custom Field Value"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Honorary"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Contributions for %{name}"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Family"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "You do not have permission to view custom field values"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Student"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Quarterly fee for family memberships"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Please select a custom field first"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Open Contributions"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Member Contributions"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "About Contribution Types"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,42 @@ admin_user_with_role =
|
||||||
raise "Failed to load admin user: #{inspect(error)}"
|
raise "Failed to load admin user: #{inspect(error)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Create system user for systemic operations (email sync, validations, cycle generation)
|
||||||
|
# This user is used by Mv.Helpers.SystemActor for operations that must always run
|
||||||
|
# Email is configurable via SYSTEM_ACTOR_EMAIL environment variable
|
||||||
|
system_user_email = Mv.Helpers.SystemActor.system_user_email()
|
||||||
|
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^system_user_email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, existing_system_user} when not is_nil(existing_system_user) ->
|
||||||
|
# System user already exists - ensure it has admin role
|
||||||
|
existing_system_user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
# System user doesn't exist - create it with admin role
|
||||||
|
# SECURITY: System user must NOT be able to log in:
|
||||||
|
# - No password (hashed_password = nil) - prevents password login
|
||||||
|
# - No OIDC ID (oidc_id = nil) - prevents OIDC login
|
||||||
|
# - This user is ONLY for internal system operations via SystemActor
|
||||||
|
# If either hashed_password or oidc_id is set, the user could potentially log in
|
||||||
|
Accounts.create_user!(%{email: system_user_email},
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email
|
||||||
|
)
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
# Log error but don't fail seeds - SystemActor will fall back to admin user
|
||||||
|
IO.puts("Warning: Failed to create system user: #{inspect(error)}")
|
||||||
|
IO.puts("SystemActor will fall back to admin user (#{admin_email})")
|
||||||
|
end
|
||||||
|
|
||||||
# Load all membership fee types for assignment
|
# Load all membership fee types for assignment
|
||||||
# Sort by name to ensure deterministic order
|
# Sort by name to ensure deterministic order
|
||||||
all_fee_types =
|
all_fee_types =
|
||||||
|
|
|
||||||
362
test/mv/helpers/system_actor_test.exs
Normal file
362
test/mv/helpers/system_actor_test.exs
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the SystemActor helper module.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Authorization
|
||||||
|
alias Mv.Accounts
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
# Helper function to ensure admin role exists
|
||||||
|
defp ensure_admin_role do
|
||||||
|
case Authorization.list_roles() do
|
||||||
|
{:ok, roles} ->
|
||||||
|
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||||
|
nil ->
|
||||||
|
{:ok, role} =
|
||||||
|
Authorization.create_role(%{
|
||||||
|
name: "Admin",
|
||||||
|
description: "Administrator with full access",
|
||||||
|
permission_set_name: "admin"
|
||||||
|
})
|
||||||
|
|
||||||
|
role
|
||||||
|
|
||||||
|
role ->
|
||||||
|
role
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:ok, role} =
|
||||||
|
Authorization.create_role(%{
|
||||||
|
name: "Admin",
|
||||||
|
description: "Administrator with full access",
|
||||||
|
permission_set_name: "admin"
|
||||||
|
})
|
||||||
|
|
||||||
|
role
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function to ensure system user exists with admin role
|
||||||
|
defp ensure_system_user(admin_role) do
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Accounts.create_user!(%{email: "system@mila.local"},
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email
|
||||||
|
)
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function to ensure admin user exists with admin role
|
||||||
|
defp ensure_admin_user(admin_role) do
|
||||||
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Accounts.create_user!(%{email: admin_email},
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email
|
||||||
|
)
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup do
|
||||||
|
admin_role = ensure_admin_role()
|
||||||
|
system_user = ensure_system_user(admin_role)
|
||||||
|
admin_user = ensure_admin_user(admin_role)
|
||||||
|
|
||||||
|
# Invalidate cache to ensure fresh load
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
%{admin_role: admin_role, system_user: system_user, admin_user: admin_user}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_system_actor/0" do
|
||||||
|
test "returns system user with admin role", %{system_user: _system_user} do
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
assert %Mv.Accounts.User{} = system_actor
|
||||||
|
assert to_string(system_actor.email) == "system@mila.local"
|
||||||
|
assert system_actor.role != nil
|
||||||
|
assert system_actor.role.permission_set_name == "admin"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to admin user if system user doesn't exist", %{admin_user: _admin_user} do
|
||||||
|
# Delete system user if it exists
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Invalidate cache to force reload
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# Should fall back to admin user
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
assert %Mv.Accounts.User{} = system_actor
|
||||||
|
assert system_actor.role != nil
|
||||||
|
assert system_actor.role.permission_set_name == "admin"
|
||||||
|
# Should be admin user, not system user
|
||||||
|
assert to_string(system_actor.email) != "system@mila.local"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "caches system actor for performance" do
|
||||||
|
# First call
|
||||||
|
actor1 = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
# Second call should return cached actor (same struct)
|
||||||
|
actor2 = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
# Should be the same struct (cached)
|
||||||
|
assert actor1.id == actor2.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates system user in test environment if none exists", %{admin_role: _admin_role} do
|
||||||
|
# In test environment, system actor should auto-create if missing
|
||||||
|
# Delete all users to test auto-creation
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Invalidate cache
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# Should auto-create system user in test environment
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
assert %Mv.Accounts.User{} = system_actor
|
||||||
|
assert to_string(system_actor.email) == "system@mila.local"
|
||||||
|
assert system_actor.role != nil
|
||||||
|
assert system_actor.role.permission_set_name == "admin"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "invalidate_cache/0" do
|
||||||
|
test "forces reload of system actor on next call" do
|
||||||
|
# Get initial actor
|
||||||
|
actor1 = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
# Invalidate cache
|
||||||
|
:ok = SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# Next call should reload (but should be same user)
|
||||||
|
actor2 = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
# Should be same user (same ID)
|
||||||
|
assert actor1.id == actor2.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_system_actor_result/0" do
|
||||||
|
test "returns ok tuple with system actor" do
|
||||||
|
assert {:ok, actor} = SystemActor.get_system_actor_result()
|
||||||
|
assert %Mv.Accounts.User{} = actor
|
||||||
|
assert actor.role.permission_set_name == "admin"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error tuple when system actor cannot be loaded" do
|
||||||
|
# Delete all users to force error
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# In test environment, it should auto-create, so this should succeed
|
||||||
|
# But if it fails, we should get an error tuple
|
||||||
|
result = SystemActor.get_system_actor_result()
|
||||||
|
|
||||||
|
# Should either succeed (auto-created) or return error
|
||||||
|
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "system_user_email/0" do
|
||||||
|
test "returns the system user email address" do
|
||||||
|
assert SystemActor.system_user_email() == "system@mila.local"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "edge cases" do
|
||||||
|
test "raises error if admin user has no role", %{admin_user: admin_user} do
|
||||||
|
# Remove role from admin user
|
||||||
|
admin_user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|
||||||
|
# Delete system user to force fallback
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# Should raise error because admin user has no role
|
||||||
|
assert_raise RuntimeError, ~r/System actor must have a role assigned/, fn ->
|
||||||
|
SystemActor.get_system_actor()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles concurrent calls without race conditions" do
|
||||||
|
# Delete system user and admin user to force creation
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
Ash.destroy!(user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# Call get_system_actor concurrently from multiple processes
|
||||||
|
tasks =
|
||||||
|
for _ <- 1..10 do
|
||||||
|
Task.async(fn -> SystemActor.get_system_actor() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
results = Task.await_many(tasks, :infinity)
|
||||||
|
|
||||||
|
# All should succeed and return the same actor
|
||||||
|
assert length(results) == 10
|
||||||
|
assert Enum.all?(results, &(&1.role.permission_set_name == "admin"))
|
||||||
|
assert Enum.all?(results, fn actor -> to_string(actor.email) == "system@mila.local" end)
|
||||||
|
|
||||||
|
# All should have the same ID (same user)
|
||||||
|
ids = Enum.map(results, & &1.id)
|
||||||
|
assert Enum.uniq(ids) |> length() == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises error if system user has wrong role", %{system_user: system_user} do
|
||||||
|
# Create a non-admin role (using read_only as it's a valid permission set)
|
||||||
|
{:ok, read_only_role} =
|
||||||
|
Authorization.create_role(%{
|
||||||
|
name: "Read Only Role",
|
||||||
|
description: "Read-only access",
|
||||||
|
permission_set_name: "read_only"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Assign wrong role to system user
|
||||||
|
system_user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# Should raise error because system user doesn't have admin role
|
||||||
|
assert_raise RuntimeError, ~r/System actor must have admin role/, fn ->
|
||||||
|
SystemActor.get_system_actor()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises error if system user has no role", %{system_user: system_user} do
|
||||||
|
# Remove role from system user
|
||||||
|
system_user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||||
|
|> Ash.update!()
|
||||||
|
|
||||||
|
SystemActor.invalidate_cache()
|
||||||
|
|
||||||
|
# Should raise error because system user has no role
|
||||||
|
assert_raise RuntimeError, ~r/System actor must have a role assigned/, fn ->
|
||||||
|
SystemActor.get_system_actor()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue