Merge branch 'main' into feature/330_import_service_skeleton

This commit is contained in:
carla 2026-01-14 12:30:40 +01:00
commit 4b41ab37bb
50 changed files with 2594 additions and 1103 deletions

View file

@ -34,10 +34,12 @@ defmodule Mv.Membership.Member do
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
import Ash.Expr
alias Mv.Helpers
require Logger
alias Mv.Membership.Helpers.VisibilityConfig
@ -118,11 +120,12 @@ defmodule Mv.Membership.Member do
# Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility
change after_transaction(fn _changeset, result, _context ->
change after_transaction(fn changeset, result, _context ->
case result do
{:ok, member} ->
if member.membership_fee_type_id && member.join_date do
handle_cycle_generation(member)
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
end
{:error, _} ->
@ -193,7 +196,9 @@ defmodule Mv.Membership.Member do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do
case regenerate_cycles_on_type_change(member) do
actor = Map.get(changeset.context, :actor)
case regenerate_cycles_on_type_change(member, actor: actor) do
{:ok, notifications} ->
# Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications}
@ -225,7 +230,8 @@ defmodule Mv.Membership.Member do
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
handle_cycle_generation(member)
actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
end
{:error, _} ->
@ -296,6 +302,41 @@ defmodule Mv.Membership.Member do
end
end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed (for test fixtures)
# In production/dev: ALL operations denied without actor (fail-closed for security)
# NoActor.check uses compile-time environment detection to prevent security issues
bypass action_type([:create, :read, :update, :destroy]) do
description "Allow system operations without actor (test environment only)"
authorize_if Mv.Authorization.Checks.NoActor
end
# SPECIAL CASE: Users can always READ their linked member
# This allows users with ANY permission set to read their own linked member
# Check using the inverse relationship: User.member_id → Member.id
bypass action_type(:read) do
description "Users can always read member linked to their account"
authorize_if expr(id == ^actor(:member_id))
end
# GENERAL: Check permissions from user's role
# HasPermission handles update permissions correctly:
# - :own_data → can update linked member (scope :linked)
# - :read_only → cannot update any member (no update permission)
# - :normal_user → can update all members (scope :all)
# - :admin → can update all members (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Forbid if no policy matched
# Ash implicitly forbids if no policy authorized
end
@doc """
Filters members list based on email match priority.
@ -363,8 +404,13 @@ defmodule Mv.Membership.Member do
user_id = user_arg[:id]
current_member_id = changeset.data.id
# Get actor from changeset context for authorization
# If no actor is present, this will fail in production (fail-closed)
actor = Map.get(changeset.context || %{}, :actor)
# Check the current state of the user in the database
case Ash.get(Mv.Accounts.User, user_id) do
# Pass actor to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
# User is free to be linked
{:ok, %{member_id: nil}} ->
:ok
@ -742,33 +788,37 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
# to be sent after transaction commits
@doc false
def regenerate_cycles_on_type_change(member) do
def regenerate_cycles_on_type_change(member, opts \\ []) do
today = Date.utc_today()
lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
# Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key)
regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
else
regenerate_cycles_new_transaction(member, today, lock_key)
regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
end
end
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do
defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
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)
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor)
end
# Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do
{:ok, notifications} ->
# Return notifications - they will be sent by the caller
notifications
@ -790,6 +840,7 @@ defmodule Mv.Membership.Member do
require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
@ -799,10 +850,21 @@ defmodule Mv.Membership.Member do
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
case Ash.read(all_unpaid_cycles_query) do
result =
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} ->
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today,
skip_lock?: skip_lock?,
actor: actor
)
{:error, reason} ->
{:error, reason}
@ -831,13 +893,14 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
if Enum.empty?(cycles_to_delete) do
# No cycles to delete, just regenerate
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
else
case delete_cycles(cycles_to_delete) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
{:error, reason} -> {:error, reason}
end
end
@ -863,11 +926,13 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id,
today: today,
skip_lock?: skip_lock?
skip_lock?: skip_lock?,
actor: actor
) do
{:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications}
@ -881,21 +946,25 @@ defmodule Mv.Membership.Member do
# based on environment (test vs production)
# This function encapsulates the common logic for cycle generation
# to avoid code duplication across different hooks
defp handle_cycle_generation(member) do
defp handle_cycle_generation(member, opts) do
actor = Keyword.get(opts, :actor)
if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member)
handle_cycle_generation_sync(member, actor: actor)
else
handle_cycle_generation_async(member)
handle_cycle_generation_async(member, actor: actor)
end
end
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member) do
defp handle_cycle_generation_sync(member, opts) do
require Logger
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today()
today: Date.utc_today(),
actor: actor
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
@ -907,9 +976,11 @@ defmodule Mv.Membership.Member do
end
# Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member) do
defp handle_cycle_generation_async(member, opts) do
actor = Keyword.get(opts, :actor)
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: false)
@ -1138,15 +1209,18 @@ defmodule Mv.Membership.Member do
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
if is_nil(custom_field_values_arg) do
extract_existing_values(changeset.data)
extract_existing_values(changeset.data, changeset)
else
extract_argument_values(custom_field_values_arg)
end
end
# Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data) do
case Ash.load(member_data, :custom_field_values) do
defp extract_existing_values(member_data, changeset) do
actor = Map.get(changeset.context, :actor)
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :custom_field_values, opts) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)

View file

@ -21,8 +21,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type:
- Member: member.user.id == actor.id (via has_one :user relationship)
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member user relationship!)
- Member: `id == actor.member_id` (User.member_id Member.id, inverse relationship)
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id Member.id User.member_id)
## Error Handling
@ -59,6 +59,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
def strict_check(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
record = get_record_from_authorizer(authorizer)
cond do
is_nil(actor) ->
@ -76,12 +77,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
{:ok, false}
true ->
strict_check_with_permissions(actor, resource, action)
strict_check_with_permissions(actor, resource, action, record)
end
end
# Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action) do
defp strict_check_with_permissions(actor, resource, action, record) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
@ -93,9 +94,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor,
resource_name
) do
:authorized -> {:ok, true}
{:filter, _} -> {:ok, :unknown}
false -> {:ok, false}
:authorized ->
{:ok, true}
{:filter, filter_expr} ->
# For strict_check on single records, evaluate the filter against the record
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
false ->
{:ok, false}
end
else
%{role: nil} ->
@ -122,9 +129,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
action = get_action_from_authorizer(authorizer)
cond do
is_nil(actor) -> nil
is_nil(action) -> nil
true -> auto_filter_with_permissions(actor, resource, action)
is_nil(actor) ->
# No actor - deny access (fail-closed)
# Return filter that never matches (expr(false) = match none)
deny_filter()
is_nil(action) ->
# Cannot determine action - deny access (fail-closed)
deny_filter()
true ->
auto_filter_with_permissions(actor, resource, action)
end
end
@ -141,21 +156,97 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor,
resource_name
) do
:authorized -> nil
{:filter, filter_expr} -> filter_expr
false -> nil
:authorized ->
# :all scope - allow all records (no filter)
# Return empty keyword list (no filtering)
[]
{:filter, filter_expr} ->
# :linked or :own scope - apply filter
# filter_expr is a keyword list from expr(...), return it directly
filter_expr
false ->
# No permission - deny access (fail-closed)
deny_filter()
end
else
_ ->
# Error case (no role, invalid permission set, etc.) - deny access (fail-closed)
deny_filter()
end
end
# Helper function to return a filter that never matches (deny all records)
# Used when authorization should be denied (fail-closed)
#
# Using `expr(false)` avoids depending on the primary key being named `:id`.
# This is more robust than [id: {:in, []}] which assumes the primary key is `:id`.
defp deny_filter do
expr(false)
end
# Helper to extract action type from authorizer
# CRITICAL: Must use action_type, not action.name!
# Action types: :create, :read, :update, :destroy
# Action names: :create_member, :update_member, etc.
# PermissionSets uses action types, not action names
#
# Prefer authorizer.action.type (stable API) over authorizer.subject (varies by context)
defp get_action_from_authorizer(authorizer) do
# Primary: Use authorizer.action.type (stable API)
case Map.get(authorizer, :action) do
%{type: action_type} when action_type in [:create, :read, :update, :destroy] ->
action_type
_ ->
# Fallback: Try authorizer.subject (for compatibility with different Ash versions/contexts)
case Map.get(authorizer, :subject) do
%{action_type: action_type} when action_type in [:create, :read, :update, :destroy] ->
action_type
%{action: %{type: action_type}}
when action_type in [:create, :read, :update, :destroy] ->
action_type
_ ->
nil
end
end
end
# Helper to extract record from authorizer for strict_check
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil
end
end
# Helper to extract action from authorizer
defp get_action_from_authorizer(authorizer) do
case authorizer.subject do
%{action: %{name: action}} -> action
%{action: action} when is_atom(action) -> action
_ -> nil
# Evaluate filter expression for strict_check on single records
# For :linked scope with Member resource: id == actor.member_id
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
case {resource_name, record} do
{"Member", %{id: member_id}} when not is_nil(member_id) ->
# Check if this member's ID matches the actor's member_id
if member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
# Check if this CFV's member_id matches the actor's member_id
if cfv_member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
_ ->
# For other cases or when record is not available, return :unknown
# This will cause Ash to use auto_filter instead
{:ok, :unknown}
end
end
@ -190,21 +281,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
end
# Scope: linked - Filter based on user relationship (resource-specific!)
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member
# IMPORTANT: Understand the relationship direction!
# - User belongs_to :member (User.member_id → Member.id)
# - Member has_one :user (inverse, no FK on Member)
defp apply_scope(:linked, actor, resource_name) do
case resource_name do
"Member" ->
# Member has_one :user → filter by user.id == actor.id
{:filter, expr(user.id == ^actor.id)}
# User.member_id → Member.id (inverse relationship)
# Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
"CustomFieldValue" ->
# CustomFieldValue belongs_to :member → member has_one :user
# Traverse: custom_field_value.member.user.id == actor.id
{:filter, expr(member.user.id == ^actor.id)}
# CustomFieldValue.member_id → Member.id → User.member_id
# Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member_id == ^actor.member_id)}
_ ->
# Fallback for other resources: try user relationship first, then user_id
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)}
# Fallback for other resources
{:filter, expr(user_id == ^actor.id)}
end
end

View file

@ -0,0 +1,74 @@
defmodule Mv.Authorization.Checks.NoActor do
@moduledoc """
Custom Ash Policy Check that allows actions when no actor is present.
**IMPORTANT:** This check ONLY works in test environment for security reasons.
In production/dev, ALL operations without an actor are denied.
## Security Note
This check uses compile-time environment detection to prevent accidental
security issues in production. In production, ALL operations (including :create
and :read) will be denied if no actor is present.
For seeds and system operations in production, use an admin actor instead:
admin_user = get_admin_user()
Ash.create!(resource, attrs, actor: admin_user)
## Usage in Policies
policies do
# Allow system operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed
# In production: ALL operations denied (fail-closed)
bypass action_type([:create, :read, :update, :destroy]) do
authorize_if NoActor
end
# Check permissions when actor is present
policy action_type([:read, :create, :update, :destroy]) do
authorize_if HasPermission
end
end
## Behavior
- In test environment: Returns `true` when actor is nil (allows all operations)
- In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed)
- Returns `false` when actor is present (delegates to other policies)
"""
use Ash.Policy.SimpleCheck
# Compile-time check: Only allow no-actor bypass in test environment
@allow_no_actor_bypass Mix.env() == :test
# Alternative (if you want to control via config):
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
@impl true
def describe(_opts) do
if @allow_no_actor_bypass do
"allows actions when no actor is present (test environment only)"
else
"denies all actions when no actor is present (production/dev - fail-closed)"
end
end
@impl true
def match?(nil, _context, _opts) do
# Actor is nil
if @allow_no_actor_bypass do
# Test environment: Allow all operations
true
else
# Production/dev: Deny all operations (fail-closed for security)
false
end
end
def match?(_actor, _context, _opts) do
# Actor is present - don't match (let other policies decide)
false
end
end

View file

@ -41,8 +41,10 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
actor = Map.get(changeset.context, :actor)
with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member) do
linked_user <- Loader.get_linked_user(member, actor) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result

View file

@ -33,7 +33,17 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
# Ensure actor is in changeset context - get it from context if available
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
@ -42,7 +52,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do
{:ok, user, member} <- get_user_and_member(record, cs) do
# 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
case record do
@ -61,15 +71,19 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
end
# Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user) do
case Loader.get_linked_member(user) do
defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
actor = Map.get(changeset.context, :actor)
case Loader.get_linked_member(user, actor) do
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
actor = Map.get(changeset.context, :actor)
case Loader.load_linked_user!(member, actor) do
{:ok, user} -> {:ok, user, member}
error -> error
end

View file

@ -2,15 +2,30 @@ defmodule Mv.EmailSync.Loader do
@moduledoc """
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
All functions accept an optional `actor` parameter that is passed to Ash operations
to ensure proper authorization checks are performed.
"""
alias Mv.Helpers
@doc """
Loads the member linked to a user, returns nil if not linked or on error.
"""
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}) do
case Ash.get(Mv.Membership.Member, id) do
Accepts optional actor for authorization.
"""
def get_linked_member(user, actor \\ nil)
def get_linked_member(%{member_id: nil}, _actor), do: nil
def get_linked_member(%{member_id: id}, actor) do
opts = Helpers.ash_actor_opts(actor)
case Ash.get(Mv.Membership.Member, id, opts) do
{:ok, member} -> member
{:error, _} -> nil
end
@ -18,9 +33,13 @@ defmodule Mv.EmailSync.Loader do
@doc """
Loads the user linked to a member, returns nil if not linked or on error.
Accepts optional actor for authorization.
"""
def get_linked_user(member) do
case Ash.load(member, :user) do
def get_linked_user(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} -> user
{:error, _} -> nil
end
@ -29,9 +48,13 @@ defmodule Mv.EmailSync.Loader do
@doc """
Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation.
Accepts optional actor for authorization.
"""
def load_linked_user!(member) do
case Ash.load(member, :user) do
def load_linked_user!(member, actor \\ nil) do
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error

27
lib/mv/helpers.ex Normal file
View file

@ -0,0 +1,27 @@
defmodule Mv.Helpers do
@moduledoc """
Shared helper functions used across the Mv application.
Provides utilities that are not specific to a single domain or layer.
"""
@doc """
Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil.
This helper ensures consistent actor handling across all Ash operations
in the application, whether called from LiveViews, changes, validations,
or other contexts.
## Examples
opts = ash_actor_opts(actor)
Ash.read(query, opts)
opts = ash_actor_opts(nil)
# => []
"""
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
def ash_actor_opts(nil), do: []
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
end

View file

@ -8,6 +8,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
alias Mv.Helpers
@doc """
Validates email uniqueness across linked Member-User pairs.
@ -29,7 +30,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data)
actor = Map.get(changeset.context || %{}, :actor)
linked_user_id = get_linked_user_id(changeset.data, actor)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
@ -38,19 +40,21 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id)
check_email_uniqueness(new_email, linked_user_id, actor)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id) do
defp check_email_uniqueness(email, exclude_user_id, actor) do
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
case Ash.read(query) do
opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} ->
:ok
@ -65,8 +69,10 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do
case Ash.load(member_data, :user) do
defp get_linked_user_id(member_data, actor) do
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :user, opts) do
{:ok, %{user: %{id: id}}} -> id
_ -> nil
end

View file

@ -28,6 +28,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
Uses PostgreSQL advisory locks to prevent race conditions when generating
cycles for the same member concurrently.
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
All functions accept an optional `actor` parameter in the `opts` keyword list
that is passed to Ash operations to ensure proper authorization checks are performed.
## Examples
# Generate cycles for a single member
@ -77,7 +86,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
case load_member(member_id) do
actor = Keyword.get(opts, :actor)
case load_member(member_id, actor: actor) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
@ -87,25 +98,27 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?)
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
end
# Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking
do_generate_cycles(member, today)
actor = Keyword.get(opts, :actor)
do_generate_cycles(member, today, actor: actor)
end
defp do_generate_cycles_with_lock(member, today, false) do
defp do_generate_cycles_with_lock(member, today, false, opts) do
lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today) do
case do_generate_cycles(member, today, actor: actor) do
{:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook)
@ -222,21 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions
defp load_member(member_id) do
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|> Ash.read_one()
|> case do
defp load_member(member_id, opts) do
actor = Keyword.get(opts, :actor)
query =
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
result =
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, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today) do
defp do_generate_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
# Reload member with relationships to ensure fresh data
case load_member(member.id) do
case load_member(member.id, actor: actor) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
@ -246,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today)
generate_missing_cycles(member, today, actor: actor)
end
{:error, reason} ->
@ -254,7 +279,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp generate_missing_cycles(member, today) do
defp generate_missing_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
@ -270,7 +296,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
else
{:ok, [], []}
end
@ -365,7 +391,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = Keyword.get(opts, :actor)
# Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
@ -380,7 +407,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true),
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
cycle_start
)
end)

View file

@ -91,7 +91,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
{:ok, custom_field} ->
action =
case socket.assigns.form.source.type do

View file

@ -32,6 +32,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true
def render(assigns) do
~H"""
@ -172,8 +175,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
page_title = action <> " " <> "Custom field value"
# Load all CustomFields and Members for the selection fields
custom_fields = Ash.read!(Mv.Membership.CustomField)
members = Ash.read!(Mv.Membership.Member)
actor = current_actor(socket)
custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
members = Ash.read!(Mv.Membership.Member, actor: actor)
{:ok,
socket
@ -224,7 +228,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
custom_field_value_params
end
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
actor = current_actor(socket)
case submit_form(socket.assigns.form, updated_params, actor) do
{:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value})

View file

@ -23,6 +23,9 @@ defmodule MvWeb.CustomFieldValueLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true
def render(assigns) do
~H"""
@ -70,17 +73,85 @@ defmodule MvWeb.CustomFieldValueLive.Index do
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))}
actor = current_actor(socket)
# Early return if no actor (prevents exceptions in unauthenticated tests)
if is_nil(actor) do
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])}
else
case Ash.read(Mv.Membership.CustomFieldValue, actor: actor) do
{:ok, custom_field_values} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, custom_field_values)}
{:error, %Ash.Error.Forbidden{}} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, gettext("You do not have permission to view custom field values"))}
{:error, error} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, format_error(error))}
end
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id)
Ash.destroy!(custom_field_value)
actor = MvWeb.LiveHelpers.current_actor(socket)
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)}
case Ash.get(Mv.Membership.CustomFieldValue, id, actor: actor) do
{:ok, custom_field_value} ->
case Ash.destroy(custom_field_value, actor: actor) do
:ok ->
{:noreply,
socket
|> stream_delete(:custom_field_values, custom_field_value)
|> put_flash(:info, gettext("Custom field value deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Custom field value not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
defp format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -90,7 +90,9 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
{:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings()

View file

@ -21,6 +21,10 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@ -172,7 +176,7 @@ defmodule MvWeb.MemberLive.Form do
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate_membership_fee_type"
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<option value="">{gettext("None")}</option>
@ -222,6 +226,8 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def mount(params, _session, socket) do
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
actor = current_actor(socket)
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
initial_custom_field_values =
@ -239,14 +245,14 @@ defmodule MvWeb.MemberLive.Form do
member =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
end
page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types
available_fee_types = load_available_fee_types(member)
available_fee_types = load_available_fee_types(member, actor)
{:ok,
socket
@ -265,53 +271,80 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def handle_event("validate", %{"member" => member_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
# Merge with existing form values to preserve unchanged fields (especially custom_field_values)
# Extract values directly from form fields to get current state
existing_values = get_existing_form_values(socket.assigns.form)
# Merge existing values with new params (new params take precedence)
merged_params = Map.merge(existing_values, member_params)
validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
# Check for interval mismatch if membership_fee_type_id changed
socket = check_interval_change(socket, member_params)
socket = check_interval_change(socket, merged_params)
{:noreply, assign(socket, form: validated_form)}
end
def handle_event(
"validate_membership_fee_type",
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
socket
) do
# Same validation as above, but triggered by select change
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
end
def handle_event("save", %{"member" => member_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do
{:ok, member} ->
notify_parent({:saved, member})
try do
actor = current_actor(socket)
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
case submit_form(socket.assigns.form, member_params, actor) do
{:ok, member} ->
handle_save_success(socket, member)
socket =
socket
|> put_flash(:info, gettext("Member %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
handle_save_forbidden(socket)
end
end
defp handle_save_success(socket, member) do
notify_parent({:saved, member})
flash_message =
case socket.assigns.form.source.type do
:create -> gettext("Member created successfully")
:update -> gettext("Member updated successfully")
other -> gettext("Member %{action} successfully", action: get_action_name(other))
end
socket =
socket
|> put_flash(:info, flash_message)
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
end
defp handle_save_forbidden(socket) do
# Handle policy violations that aren't properly displayed in forms
# AshPhoenix.Form doesn't implement FormData.Error protocol for Forbidden errors
action = get_action_name(socket.assigns.form.source.type)
error_message =
gettext("You do not have permission to %{action} members.", action: action)
{:noreply, put_flash(socket, :error, error_message)}
end
defp get_action_name(:create), do: gettext("create")
defp get_action_name(:update), do: gettext("update")
defp get_action_name(other), do: to_string(other)
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{member: member}} = socket) do
defp assign_form(%{assigns: assigns} = socket) do
member = assigns.member
actor = assigns[:current_user] || assigns.current_user
form =
if member do
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
{:ok, member} = Ash.load(member, [custom_field_values: [:custom_field]], actor: actor)
existing_custom_field_values =
member.custom_field_values
@ -342,7 +375,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership,
as: "member",
params: params,
forms: [auto?: true]
forms: [auto?: true],
actor: actor
)
missing_custom_field_values =
@ -360,7 +394,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership,
as: "member",
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
forms: [auto?: true]
forms: [auto?: true],
actor: actor
)
end
@ -375,11 +410,11 @@ defmodule MvWeb.MemberLive.Form do
# Helper Functions
# -----------------------------------------------------------------
defp load_available_fee_types(member) do
defp load_available_fee_types(member, actor) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees)
|> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval
if member && member.membership_fee_type do
@ -453,4 +488,167 @@ defmodule MvWeb.MemberLive.Form do
defp custom_field_input_type(:date), do: "date"
defp custom_field_input_type(:email), do: "email"
defp custom_field_input_type(_), do: "text"
# -----------------------------------------------------------------
# Helper Functions for Form Value Preservation
# -----------------------------------------------------------------
# Helper to extract existing form values to preserve them when only one field changes
# This ensures custom_field_values and other fields are preserved when only the dropdown changes
defp get_existing_form_values(form) do
%{}
|> extract_form_value(form, :first_name, &to_string/1)
|> extract_form_value(form, :last_name, &to_string/1)
|> extract_form_value(form, :email, &to_string/1)
|> extract_form_value(form, :street, &to_string/1)
|> extract_form_value(form, :house_number, &to_string/1)
|> extract_form_value(form, :postal_code, &to_string/1)
|> extract_form_value(form, :city, &to_string/1)
|> extract_form_value(form, :join_date, &format_date_value/1)
|> extract_form_value(form, :exit_date, &format_date_value/1)
|> extract_form_value(form, :notes, &to_string/1)
|> extract_form_value(form, :membership_fee_type_id, &to_string/1)
|> extract_form_value(form, :membership_fee_start_date, &format_date_value/1)
|> extract_custom_field_values(form)
end
# Helper to extract a single form field value
defp extract_form_value(acc, form, field, formatter) do
if form[field] && form[field].value do
Map.put(acc, to_string(field), formatter.(form[field].value))
else
acc
end
end
# Extracts custom field values from the form structure
# The form is a Phoenix.HTML.Form with source being AshPhoenix.Form
# Custom field values are in form.source.params["custom_field_values"] as a map
defp extract_custom_field_values(acc, form) do
cfv_params = get_custom_field_values_params(form)
if map_size(cfv_params) > 0 do
custom_field_values = convert_cfv_params_to_list(cfv_params)
Map.put(acc, "custom_field_values", custom_field_values)
else
acc
end
end
# Gets custom_field_values from form params
defp get_custom_field_values_params(form) do
ash_form = form.source
if ash_form && Map.has_key?(ash_form, :params) && ash_form.params["custom_field_values"] do
ash_form.params["custom_field_values"]
else
%{}
end
end
# Converts custom field values map to sorted list
defp convert_cfv_params_to_list(cfv_params) do
cfv_params
|> Map.to_list()
|> Enum.sort_by(&parse_numeric_key/1)
|> Enum.map(&build_custom_field_value/1)
end
# Parses numeric key for sorting
defp parse_numeric_key({key, _}) do
case Integer.parse(key) do
{num, _} -> num
:error -> 999_999
end
end
# Builds a custom field value map from params
defp build_custom_field_value({_key, cfv_map}) do
%{
"custom_field_id" => Map.get(cfv_map, "custom_field_id", ""),
"value" => extract_custom_field_value_from_map(Map.get(cfv_map, "value", %{}))
}
end
# Extracts the value map structure from a custom field value
# Handles both map format and Ash.Union struct format
defp extract_custom_field_value_from_map(%Ash.Union{} = union) do
union_type = Atom.to_string(union.type)
%{
"_union_type" => union_type,
"type" => union_type,
"value" => format_custom_field_value(union.value)
}
end
defp extract_custom_field_value_from_map(value_map) when is_map(value_map) do
union_type = extract_union_type_from_map(value_map)
value = Map.get(value_map, "value") || Map.get(value_map, :value)
%{
"_union_type" => union_type,
"type" => union_type,
"value" => format_custom_field_value(value)
}
end
defp extract_custom_field_value_from_map(_),
do: %{"_union_type" => "", "type" => "", "value" => ""}
# Extracts union type from map, checking various possible locations
defp extract_union_type_from_map(value_map) do
cond do
has_non_empty_string(value_map, "_union_type") ->
Map.get(value_map, "_union_type")
has_non_empty_atom(value_map, :_union_type) ->
to_string(Map.get(value_map, :_union_type))
has_atom_type(value_map) ->
Atom.to_string(Map.get(value_map, :type))
has_string_type(value_map) ->
Map.get(value_map, "type")
true ->
""
end
end
# Helper to check if map has non-empty string value
defp has_non_empty_string(map, key) do
value = Map.get(map, key)
value && value != ""
end
# Helper to check if map has non-empty atom value
defp has_non_empty_atom(map, key) do
value = Map.get(map, key)
value && value != ""
end
# Helper to check if map has atom type
defp has_atom_type(map) do
value = Map.get(map, :type)
value && is_atom(value)
end
# Helper to check if map has string type
defp has_string_type(map) do
value = Map.get(map, "type")
value && is_binary(value)
end
# Formats custom field value based on its type
defp format_custom_field_value(%Date{} = date), do: Date.to_iso8601(date)
defp format_custom_field_value(%Decimal{} = decimal), do: Decimal.to_string(decimal, :normal)
defp format_custom_field_value(value) when is_boolean(value), do: to_string(value)
defp format_custom_field_value(value) when is_binary(value), do: value
defp format_custom_field_value(value), do: to_string(value)
# Formats date value (Date or string) to string
defp format_date_value(%Date{} = date), do: Date.to_iso8601(date)
defp format_date_value(value) when is_binary(value), do: value
defp format_date_value(_), do: ""
end

View file

@ -27,8 +27,11 @@ defmodule MvWeb.MemberLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Ash.Query
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias MvWeb.Helpers.DateFormatter
@ -55,20 +58,21 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def mount(_params, session, socket) do
# Load custom fields that should be shown in overview (for display)
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
# and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing.
# Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
# This is appropriate for initialization errors that should be visible to the user.
actor = current_actor(socket)
custom_fields_visible =
Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(actor: actor)
# Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(actor: actor)
# Load settings once to avoid N+1 queries
settings =
@ -130,13 +134,41 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
# This ensures users see error messages if deletion fails (e.g., permission denied)
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
actor = current_actor(socket)
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply, assign(socket, :members, updated_members)}
case Ash.get(Mv.Membership.Member, id, actor: actor) do
{:ok, member} ->
case Ash.destroy(member, actor: actor) do
:ok ->
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply,
socket
|> assign(:members, updated_members)
|> put_flash(:info, gettext("Member deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
@impl true
@ -236,6 +268,24 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Helper to format errors for display
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn error ->
case error do
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(error)
end
end)
Enum.join(error_messages, ", ")
end
defp format_error(error) do
inspect(error)
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@ -676,9 +726,9 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.custom_fields_visible
)
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
# This is appropriate for data loading in LiveViews
members = Ash.read!(query)
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
members = Ash.read!(query, actor: actor)
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore

View file

@ -20,6 +20,9 @@ defmodule MvWeb.MemberLive.Show do
"""
use MvWeb, :live_view
import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers
@ -148,9 +151,9 @@ defmodule MvWeb.MemberLive.Show do
</div>
<%!-- Custom Fields Section --%>
<%= if is_list(@custom_fields) && Enum.any?(@custom_fields) do %>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Additional Data Fields")}>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
@ -220,6 +223,7 @@ defmodule MvWeb.MemberLive.Show do
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
/>
<% end %>
</Layouts.app>
@ -233,15 +237,15 @@ defmodule MvWeb.MemberLive.Show do
@impl true
def handle_params(%{"id" => id}, _, socket) do
# Load custom fields for display
# Note: Each page load starts a new LiveView process, so caching with
# assign_new is not necessary here (mount creates a fresh socket each time)
custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
actor = current_actor(socket)
socket = assign(socket, :custom_fields, custom_fields)
# Load custom fields once using assign_new to avoid repeated queries
socket =
assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
end)
query =
Mv.Membership.Member
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show do
membership_fee_cycles: [:membership_fee_type]
])
member = Ash.read_one!(query)
member = Ash.read_one!(query, actor: actor)
# Calculate last and current cycle status from loaded cycles
last_cycle_status = get_last_cycle_status(member)

View file

@ -13,12 +13,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
use MvWeb, :live_component
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
@ -63,7 +65,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete All Cycles")}
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")}
@ -168,7 +170,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete Cycle")}
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
@ -329,14 +331,16 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
<label class="label">
<span class="label-text-alt">
{gettext("The cycle will be calculated based on this date and the interval.")}
{gettext(
"The cycle period will be calculated based on this date and the interval."
)}
</span>
</label>
</div>
<%= if @create_cycle_date do %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Cycle")}</span>
<span class="label-text">{gettext("Cycle Period")}</span>
</label>
<div class="text-sm text-base-content/70">
{format_create_cycle_period(
@ -388,6 +392,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true
def update(assigns, socket) do
member = assigns.member
actor = assigns.current_user
# Load cycles if not already loaded
cycles =
@ -401,7 +406,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member)
available_fee_types = get_available_fee_types(member, actor)
{:ok,
socket
@ -422,7 +427,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
# Remove membership fee type
case update_member_fee_type(socket.assigns.member, nil) do
actor = current_actor(socket)
case update_member_fee_type(socket.assigns.member, nil, actor) do
{:ok, updated_member} ->
send(self(), {:member_updated, updated_member})
@ -430,7 +437,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:member, updated_member)
|> assign(:cycles, [])
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
@ -441,7 +451,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
member = socket.assigns.member
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
actor = current_actor(socket)
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees, actor: actor)
# Check if interval matches
interval_warning =
@ -459,15 +470,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
else
case update_member_fee_type(member, fee_type_id) do
actor = current_actor(socket)
case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
updated_member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -482,7 +500,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -503,7 +524,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
:suspended -> :mark_as_suspended
end
case Ash.update(cycle, action: action, domain: MembershipFees) do
actor = current_actor(socket)
case Ash.update(cycle, action: action, domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -533,16 +556,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("regenerate_cycles", _params, socket) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
actor = current_actor(socket)
case CycleGenerator.generate_cycles_for_member(member.id) do
case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -572,7 +601,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
actor = current_actor(socket)
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :editing_cycle, cycle)}
end
@ -589,9 +619,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
case Decimal.parse(normalized_amount_str) do
{amount, _} when is_struct(amount, Decimal) ->
actor = current_actor(socket)
case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update(domain: MembershipFees) do
|> Ash.update(domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -616,7 +648,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
actor = current_actor(socket)
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :deleting_cycle, cycle)}
end
@ -627,8 +660,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
actor = current_actor(socket)
case Ash.destroy(cycle, domain: MembershipFees) do
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
:ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
@ -699,12 +733,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if deleted_count > 0 do
# Reload member to get updated cycles
actor = current_actor(socket)
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
updated_cycles =
Enum.sort_by(
@ -786,15 +825,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
membership_fee_type_id: member.membership_fee_type_id
}
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
actor = current_actor(socket)
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees, actor: actor) do
{:ok, _new_cycle} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
cycles =
Enum.sort_by(
@ -842,11 +886,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions
defp get_available_fee_types(member) do
defp get_available_fee_types(member, actor) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval
if member.membership_fee_type do
@ -858,12 +902,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
end
defp update_member_fee_type(member, fee_type_id) do
defp update_member_fee_type(member, fee_type_id, actor) do
attrs = %{membership_fee_type_id: fee_type_id}
member
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|> Ash.update(domain: Membership)
|> Ash.update(domain: Membership, actor: actor)
end
defp find_cycle(cycles, cycle_id) do

View file

@ -63,7 +63,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
Map.put(params, "include_joining_cycle", false)
end
case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, normalized_params, actor) do
{:ok, updated_settings} ->
{:noreply,
socket

View file

@ -13,6 +13,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
require Ash.Query
alias Mv.Membership.Member
@ -305,7 +308,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
if socket.assigns.show_amount_warning do
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
else
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
actor = current_actor(socket)
case submit_form(socket.assigns.form, params, actor) do
{:ok, membership_fee_type} ->
notify_parent({:saved, membership_fee_type})
@ -380,7 +385,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
@spec get_affected_member_count(String.t()) :: non_neg_integer()
@spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
# Checks if amount changed and updates socket assigns accordingly
defp check_amount_change(socket, params) do
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
@ -428,7 +433,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket.assigns.affected_member_count
else
# Warning being shown for first time, calculate count
get_affected_member_count(socket.assigns.membership_fee_type.id)
get_affected_member_count(socket.assigns.membership_fee_type.id, current_actor(socket))
end
socket
@ -446,8 +451,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|> assign(:pending_amount, nil)
end
defp get_affected_member_count(fee_type_id) do
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
defp get_affected_member_count(fee_type_id, actor) do
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id),
actor: actor
) do
{:ok, count} -> count
_ -> 0
end

View file

@ -14,6 +14,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query
alias Mv.Membership
@ -24,8 +27,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
@impl true
def mount(_params, _session, socket) do
fee_types = load_membership_fee_types()
member_counts = load_member_counts(fee_types)
actor = current_actor(socket)
fee_types = load_membership_fee_types(actor)
member_counts = load_member_counts(fee_types, actor)
{:ok,
socket
@ -129,18 +133,43 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees)
actor = current_actor(socket)
case Ash.destroy(fee_type, domain: MembershipFees) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
{:ok, fee_type} ->
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
put_flash(
socket,
:error,
gettext("You do not have permission to access this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
@ -149,14 +178,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
# Helper functions
defp load_membership_fee_types do
defp load_membership_fee_types(actor) do
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees)
|> Ash.read!(domain: MembershipFees, actor: actor)
end
# Loads all member counts for fee types in a single query to avoid N+1 queries
defp load_member_counts(fee_types) do
defp load_member_counts(fee_types, actor) do
fee_type_ids = Enum.map(fee_types, & &1.id)
# Load all members with membership_fee_type_id in a single query
@ -164,7 +193,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
Member
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|> Ash.Query.select([:membership_fee_type_id])
|> Ash.read!(domain: Membership)
|> Ash.read!(domain: Membership, actor: actor)
# Group by membership_fee_type_id and count
members

View file

@ -162,7 +162,9 @@ defmodule MvWeb.RoleLive.Form do
end
def handle_event("save", %{"role" => role_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
{:ok, role} ->
notify_parent({:saved, role})

View file

@ -118,7 +118,7 @@ defmodule MvWeb.RoleLive.Index do
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
opts = if actor, do: [actor: actor], else: []
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
case Authorization.list_roles(opts) do
{:ok, roles} -> Enum.sort_by(roles, & &1.name)

View file

@ -33,6 +33,9 @@ defmodule MvWeb.UserLive.Form do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true
def render(assigns) do
~H"""
@ -258,10 +261,12 @@ defmodule MvWeb.UserLive.Form do
@impl true
def mount(params, _session, socket) do
actor = current_actor(socket)
user =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@ -300,6 +305,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
@ -307,7 +313,7 @@ defmodule MvWeb.UserLive.Form do
socket =
if Map.has_key?(user_params, "email") do
user_email = user_params["email"]
members = load_members_for_linking(user_email, socket.assigns.member_search_query)
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
assign(socket, form: validated_form, available_members: members)
else
@ -317,62 +323,30 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
actor = current_actor(socket)
# First save the user without member changes
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
case submit_form(socket.assigns.form, user_params, actor) do
{:ok, user} ->
# Then handle member linking/unlinking as a separate step
result =
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil})
# No changes to member relationship
true ->
{:ok, user}
end
case result do
{:ok, updated_user} ->
notify_parent({:saved, updated_user})
socket =
socket
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
{:error, error} ->
# Show user-friendly error from member linking/unlinking
error_message = extract_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to link member: %{error}", error: error_message)
)}
end
handle_member_linking(socket, user, actor)
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}
end
@impl true
def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1
@ -389,6 +363,7 @@ defmodule MvWeb.UserLive.Form do
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
@ -404,23 +379,27 @@ defmodule MvWeb.UserLive.Form do
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn ->
select_focused_member(socket)
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys
{:noreply, socket}
end
@impl true
def handle_event("search_members", %{"member_search" => query}, socket) do
socket =
socket
@ -432,6 +411,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
@impl true
def handle_event("select_member", %{"id" => member_id}, socket) do
# Find the selected member to get their name
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
@ -448,27 +428,82 @@ defmodule MvWeb.UserLive.Form do
|> assign(:selected_member_name, member_name)
|> assign(:unlink_member, false)
|> assign(:show_member_dropdown, false)
|> assign(:member_search_query, member_name)
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
@impl true
def handle_event("unlink_member", _params, socket) do
# Set flag to unlink member on save
# Clear all member selection state and keep dropdown hidden
socket =
socket
|> assign(:unlink_member, true)
|> assign(:selected_member_id, nil)
|> assign(:selected_member_name, nil)
|> assign(:member_search_query, "")
|> assign(:unlink_member, true)
|> assign(:show_member_dropdown, false)
|> load_initial_members()
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor)
case result do
{:ok, updated_user} ->
handle_save_success(socket, updated_user)
{:error, error} ->
handle_member_link_error(socket, error)
end
end
defp perform_member_link_action(socket, user, actor) do
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor
)
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
# No changes to member relationship
true ->
{:ok, user}
end
end
defp handle_save_success(socket, updated_user) do
notify_parent({:saved, updated_user})
action = get_action_name(socket.assigns.form.source.type)
socket =
socket
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
end
defp get_action_name(:create), do: gettext("created")
defp get_action_name(:update), do: gettext("updated")
defp get_action_name(other), do: to_string(other)
defp handle_member_link_error(socket, error) do
error_message = extract_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to link member: %{error}", error: error_message)
)}
end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@ -497,18 +532,21 @@ defmodule MvWeb.UserLive.Form do
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
actor = current_actor(socket)
form =
if user do
# For existing users, use admin password action if password fields are shown
action = if show_password_fields, do: :admin_set_password, else: :update_user
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user")
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
else
# For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
domain: Mv.Accounts,
as: "user"
as: "user",
actor: actor
)
end
@ -524,7 +562,7 @@ defmodule MvWeb.UserLive.Form do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, "")
members = load_members_for_linking(user_email, "", socket)
# Dropdown should ALWAYS be hidden initially
# It will only show when user focuses the input field (show_member_dropdown event)
@ -539,12 +577,15 @@ defmodule MvWeb.UserLive.Form do
user = socket.assigns.user
user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, query)
members = load_members_for_linking(user_email, query, socket)
assign(socket, available_members: members)
end
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
defp load_members_for_linking(user_email, search_query) do
@spec load_members_for_linking(String.t() | nil, String.t() | nil, Phoenix.LiveView.Socket.t()) ::
[
Mv.Membership.Member.t()
]
defp load_members_for_linking(user_email, search_query, socket) do
user_email_str = if user_email, do: to_string(user_email), else: nil
search_query_str = if search_query && search_query != "", do: search_query, else: nil
@ -555,20 +596,27 @@ defmodule MvWeb.UserLive.Form do
search_query: search_query_str
})
case Ash.read(query, domain: Mv.Membership) do
{:ok, members} ->
# Apply email match filter if user_email is provided
if user_email_str do
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
else
members
end
actor = current_actor(socket)
{:error, _} ->
[]
# Early return if no actor (prevents exceptions in unauthenticated tests)
if is_nil(actor) do
[]
else
case Ash.read(query, domain: Mv.Membership, actor: actor) do
{:ok, members} -> apply_email_filter(members, user_email_str)
{:error, _} -> []
end
end
end
@spec apply_email_filter([Mv.Membership.Member.t()], String.t() | nil) ::
[Mv.Membership.Member.t()]
defp apply_email_filter(members, nil), do: members
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
end
# Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
@ -576,10 +624,10 @@ defmodule MvWeb.UserLive.Form do
case List.first(errors) do
%{message: message} when is_binary(message) -> message
%{field: field, message: message} -> "#{field}: #{message}"
_ -> "Unknown error"
_ -> gettext("Unknown error")
end
end
defp extract_error_message(error) when is_binary(error), do: error
defp extract_error_message(_), do: "Unknown error"
defp extract_error_message(_), do: gettext("Unknown error")
end

View file

@ -23,9 +23,13 @@ defmodule MvWeb.UserLive.Index do
use MvWeb, :live_view
import MvWeb.TableComponents
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true
def mount(_params, _session, socket) do
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
actor = current_actor(socket)
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member], actor: actor)
sorted = Enum.sort_by(users, & &1.email)
{:ok,
@ -39,11 +43,41 @@ defmodule MvWeb.UserLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
Ash.destroy!(user, domain: Mv.Accounts)
actor = current_actor(socket)
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
{:noreply, assign(socket, :users, updated_users)}
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
{:ok, user} ->
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
{:noreply,
socket
|> assign(:users, updated_users)
|> put_flash(:info, gettext("User deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this user")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
# Selects one user in the list of users
@ -104,4 +138,12 @@ defmodule MvWeb.UserLive.Index do
defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2
defp sort_fun(:desc), do: &>=/2
defp format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -26,6 +26,9 @@ defmodule MvWeb.UserLive.Show do
"""
use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true
def render(assigns) do
~H"""
@ -70,7 +73,8 @@ defmodule MvWeb.UserLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
actor = current_actor(socket)
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
{:ok,
socket

View file

@ -59,4 +59,53 @@ defmodule MvWeb.LiveHelpers do
user
end
end
@doc """
Helper function to get the current actor (user) from socket assigns.
Provides consistent access pattern across all LiveViews.
Returns nil if no current_user is present.
## Examples
actor = current_actor(socket)
members = Membership.list_members!(actor: actor)
"""
@spec current_actor(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
def current_actor(socket) do
socket.assigns[:current_user] || socket.assigns.current_user
end
@doc """
Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil.
Delegates to `Mv.Helpers.ash_actor_opts/1` for consistency across the application.
## Examples
opts = ash_actor_opts(actor)
Ash.read(query, opts)
"""
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
defdelegate ash_actor_opts(actor), to: Mv.Helpers
@doc """
Submits an AshPhoenix form with consistent actor handling.
This wrapper ensures that actor is always passed via `action_opts`
in a consistent manner across all LiveViews.
## Examples
case submit_form(form, params, actor) do
{:ok, resource} -> # success
{:error, form} -> # validation errors
end
"""
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
def submit_form(form, params, actor) do
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
end
end