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] on: [:create, :update]
# Validate default_membership_fee_type_id exists if set # Validate default_membership_fee_type_id exists if set
validate fn changeset, _context -> validate fn changeset, context ->
fee_type_id = fee_type_id =
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id) Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
if fee_type_id do 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, _} ->
:ok :ok

View file

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

View file

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

View file

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

View file

@ -27,6 +27,10 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
Modified changeset with email synchronization applied, or original changeset Modified changeset with email synchronization applied, or original changeset
if recursion detected. 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 @impl true
def change(changeset, _opts, context) do def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses # 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. Provides utilities that are not specific to a single domain or layer.
""" """
require Ash.Query
@doc """ @doc """
Converts an actor to Ash options list for authorization. Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil. 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() @spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
def ash_actor_opts(nil), do: [] def ash_actor_opts(nil), do: []
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor] 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 end

View file

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

View file

@ -10,7 +10,7 @@ alias Mv.MembershipFees.CycleGenerator
require Ash.Query 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 <- [ for fee_type_attrs <- [
%{ %{
name: "Standard (Jährlich)", name: "Standard (Jährlich)",
@ -39,7 +39,12 @@ for fee_type_attrs <- [
] do ] do
MembershipFeeType MembershipFeeType
|> Ash.Changeset.for_create(:create, fee_type_attrs) |> 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 end
for attrs <- [ for attrs <- [
@ -299,12 +304,12 @@ case Accounts.User
IO.puts("SystemActor will fall back to admin user (#{admin_email})") IO.puts("SystemActor will fall back to admin user (#{admin_email})")
end 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 # Sort by name to ensure deterministic order
all_fee_types = all_fee_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|> Enum.to_list() |> Enum.to_list()
# Create sample members for testing - use upsert to prevent duplicates # 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 # Create a valid fee type
{:ok, fee_type} = {:ok, fee_type} =
Ash.create(MembershipFeeType, %{ Ash.create(
name: "Test Fee Type #{System.unique_integer([:positive])}", MembershipFeeType,
amount: Decimal.new("100.00"), %{
interval: :yearly name: "Test Fee Type #{System.unique_integer([:positive])}",
}) amount: Decimal.new("100.00"),
interval: :yearly
},
actor: actor
)
# Setting a valid fee type should work # Setting a valid fee type should work
{:ok, updated} = {:ok, updated} =
settings settings
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{ |> Ash.Changeset.for_update(
default_membership_fee_type_id: fee_type.id :update_membership_fee_settings,
}) %{
default_membership_fee_type_id: fee_type.id
},
actor: actor
)
|> Ash.update(actor: actor) |> Ash.update(actor: actor)
assert updated.default_membership_fee_type_id == fee_type.id 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}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid? assert changeset.valid?
end end
@ -68,7 +68,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid? refute changeset.valid?
assert %{errors: errors} = changeset 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}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid? assert changeset.valid?
end end
@ -102,7 +102,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset = changeset =
member member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor) |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|> ValidateSameInterval.change(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid? refute changeset.valid?
assert %{errors: errors} = changeset assert %{errors: errors} = changeset
@ -120,7 +120,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
changeset = changeset =
member member
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor) |> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|> ValidateSameInterval.change(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
assert changeset.valid? assert changeset.valid?
end end
@ -136,7 +136,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id)) error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
assert error.message =~ "yearly" 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}, |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
actor: actor actor: actor
) )
|> ValidateSameInterval.change(%{}, %{}) |> ValidateSameInterval.change(%{}, %{actor: actor})
refute changeset.valid?, refute changeset.valid?,
"Should prevent change from #{interval1} to #{interval2}" "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) member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :paid}, 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 assert updated.status == :unpaid
end end
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member = create_member(%{membership_fee_type_id: fee_type.id}, actor) member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
cycle = create_cycle(member, fee_type, %{status: :suspended}, 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 assert updated.status == :unpaid
end end
end end

View file

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