System Actor Mode for Systemic Flows closes #348 #361

Merged
moritz merged 16 commits from feature/348_system_actor into main 2026-01-21 08:36:41 +01:00
16 changed files with 1106 additions and 763 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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