Member/Setting/validations: domain, actor, and seeds

- setting.ex: domain/authorize for default_membership_fee_type_id check
- validate_same_interval: require membership_fee_type (no None)
- set_membership_fee_start_date: domain/actor for fee type lookup
- Validations: domain/authorize for cross-resource checks
- helpers.ex, email_sync change, seeds.exs actor/authorize fixes
- Update related tests
This commit is contained in:
Moritz 2026-02-03 23:52:16 +01:00
parent 5889683854
commit 5ed41555e9
13 changed files with 118 additions and 55 deletions

View file

@ -155,12 +155,23 @@ defmodule Mv.Membership.Setting do
on: [:create, :update]
# Validate default_membership_fee_type_id exists if set
validate fn changeset, _context ->
validate fn changeset, context ->
fee_type_id =
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
if fee_type_id do
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do
# Actor may be in changeset.context (action context) or validation context
ctx = changeset.context || %{}
actor =
get_in(ctx, [:private, :actor]) ||
Map.get(ctx, :actor) ||
(context && Map.get(context, :actor))
# Check existence only; action is already restricted by policy (e.g. admin).
opts = [domain: Mv.MembershipFees, authorize?: false]
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id, opts) do
{:ok, _} ->
:ok

View file

@ -31,12 +31,12 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
alias Mv.MembershipFees.CalendarCycles
@impl true
def change(changeset, _opts, _context) do
def change(changeset, _opts, context) do
# Only calculate if membership_fee_start_date is not already set
if has_start_date?(changeset) do
changeset
else
calculate_and_set_start_date(changeset)
calculate_and_set_start_date(changeset, context)
end
end
@ -56,10 +56,13 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
end
end
defp calculate_and_set_start_date(changeset) do
defp calculate_and_set_start_date(changeset, context) do
actor = Map.get(context || %{}, :actor)
opts = if actor, do: [actor: actor], else: []
with {:ok, join_date} <- get_join_date(changeset),
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
{:ok, interval} <- get_interval(membership_fee_type_id),
{:ok, interval} <- get_interval(membership_fee_type_id, opts),
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
@ -118,8 +121,8 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
end
end
defp get_interval(membership_fee_type_id) do
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
defp get_interval(membership_fee_type_id, opts) do
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id, opts) do
{:ok, %{interval: interval}} -> {:ok, interval}
{:error, _} -> {:error, :membership_fee_type_not_found}
end

View file

@ -19,9 +19,9 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
def change(changeset, _opts, context) do
if changing_membership_fee_type?(changeset) do
validate_interval_match(changeset)
validate_interval_match(changeset, context)
else
changeset
end
@ -33,9 +33,10 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
end
# Validate that the new type has the same interval as the current type
defp validate_interval_match(changeset) do
defp validate_interval_match(changeset, context) do
current_type_id = get_current_type_id(changeset)
new_type_id = get_new_type_id(changeset)
actor = Map.get(context || %{}, :actor)
cond do
# If no current type, allow any change (first assignment)
@ -48,13 +49,13 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
# Both types exist - validate intervals match
true ->
validate_intervals_match(changeset, current_type_id, new_type_id)
validate_intervals_match(changeset, current_type_id, new_type_id, actor)
end
end
# Validates that intervals match when both types exist
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
case get_intervals(current_type_id, new_type_id) do
defp validate_intervals_match(changeset, current_type_id, new_type_id, actor) do
case get_intervals(current_type_id, new_type_id, actor) do
{:ok, current_interval, new_interval} ->
if current_interval == new_interval do
changeset
@ -85,11 +86,16 @@ defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
end
end
# Get intervals for both types
defp get_intervals(current_type_id, new_type_id) do
# Get intervals for both types (actor required for authorization when resource has policies)
defp get_intervals(current_type_id, new_type_id, actor) do
alias Mv.MembershipFees.MembershipFeeType
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
opts = if actor, do: [actor: actor], else: []
case {
Ash.get(MembershipFeeType, current_type_id, opts),
Ash.get(MembershipFeeType, new_type_id, opts)
} do
{{:ok, current_type}, {:ok, new_type}} ->
{:ok, current_type.interval, new_type.interval}

View file

@ -81,7 +81,7 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> maybe_exclude_id(exclude_member_id)
|> Mv.Helpers.query_exclude_id(exclude_member_id)
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
@ -101,7 +101,4 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -27,6 +27,10 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
Modified changeset with email synchronization applied, or original changeset
if recursion detected.
"""
# Ash 3.12+ calls this to decide whether to run the change in certain contexts.
@impl true
def has_change?, do: true
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses

View file

@ -5,6 +5,8 @@ defmodule Mv.Helpers do
Provides utilities that are not specific to a single domain or layer.
"""
require Ash.Query
@doc """
Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil.
@ -24,4 +26,22 @@ defmodule Mv.Helpers do
@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]
@doc """
Returns the query unchanged if `exclude_id` is nil; otherwise adds a filter `id != ^exclude_id`.
Used in uniqueness validations that must exclude the current record (e.g. name uniqueness
on update, duplicate association checks). Call with the record's primary key to exclude it
from the result set.
## Examples
query
|> Ash.Query.filter(name == ^name)
|> Mv.Helpers.query_exclude_id(current_id)
"""
@spec query_exclude_id(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
def query_exclude_id(query, nil), do: query
def query_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -56,7 +56,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
|> Mv.Helpers.query_exclude_id(exclude_user_id)
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
@ -76,7 +76,4 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -10,7 +10,7 @@ alias Mv.MembershipFees.CycleGenerator
require Ash.Query
# Create example membership fee types
# Create example membership fee types (no admin user yet; skip authorization for bootstrap)
for fee_type_attrs <- [
%{
name: "Standard (Jährlich)",
@ -39,7 +39,12 @@ for fee_type_attrs <- [
] do
MembershipFeeType
|> Ash.Changeset.for_create(:create, fee_type_attrs)
|> Ash.create!(upsert?: true, upsert_identity: :unique_name)
|> Ash.create!(
upsert?: true,
upsert_identity: :unique_name,
authorize?: false,
domain: Mv.MembershipFees
)
end
for attrs <- [
@ -299,12 +304,12 @@ case Accounts.User
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 (admin actor for authorization)
# Sort by name to ensure deterministic order
all_fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|> Enum.to_list()
# Create sample members for testing - use upsert to prevent duplicates

View file

@ -54,18 +54,26 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
# Create a valid fee type
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
Ash.create(
MembershipFeeType,
%{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :yearly
})
},
actor: actor
)
# Setting a valid fee type should work
{:ok, updated} =
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|> Ash.Changeset.for_update(
:update_membership_fee_settings,
%{
default_membership_fee_type_id: fee_type.id
})
},
actor: actor
)
|> Ash.update(actor: actor)
assert updated.default_membership_fee_type_id == fee_type.id

View file

@ -52,7 +52,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid?
end
@ -68,7 +68,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid?
assert %{errors: errors} = changeset
@ -90,7 +90,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid?
end
@ -102,7 +102,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid?
assert %{errors: errors} = changeset
@ -120,7 +120,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid?
end
@ -136,7 +136,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
assert error.message =~ "yearly"
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
actor: actor
)
|> ValidateSameInterval.change(%{}, %{})
|> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid?,
"Should prevent change from #{interval1} to #{interval2}"

View file

@ -151,7 +151,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
end

View file

@ -155,9 +155,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|> Ash.Changeset.for_update(
:update_membership_fee_settings,
%{
default_membership_fee_type_id: fee_type.id
})
},
actor: actor
)
|> Ash.update!(actor: actor)
# Try to delete
@ -176,9 +180,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|> Ash.Changeset.for_update(
:update_membership_fee_settings,
%{
default_membership_fee_type_id: fee_type.id
})
},
actor: actor
)
|> Ash.update!(actor: actor)
# Create a member without explicitly setting membership_fee_type_id

View file

@ -264,9 +264,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|> Ash.Changeset.for_update(
:update_membership_fee_settings,
%{
default_membership_fee_type_id: fee_type.id
})
},
actor: actor
)
|> Ash.update!(actor: actor)
# Try to delete