Return resource A from an action on resource B

I am working on an invite-only application. I have a User resource and an Invitation resource. I would like to create an :accept_invitation action which validates an invitation record, creates a user, and returns the user. This is what I have attempted so far:
# on the invitation resource
update :create_user_with_invitation do
validate absent(:used_at)
validate compare(:expires_at, greater_than: &DateTime.utc_now/0)
change set_attribute(:used_at, &DateTime.utc_now/0)
change Dreng.Changes.CreateUserFromInvitation
end
# on the invitation resource
update :create_user_with_invitation do
validate absent(:used_at)
validate compare(:expires_at, greater_than: &DateTime.utc_now/0)
change set_attribute(:used_at, &DateTime.utc_now/0)
change Dreng.Changes.CreateUserFromInvitation
end
The CreateUserFromInvitation change looks like this:
defmodule Dreng.Changes.CreateUserFromInvitation do
use Ash.Resource.Change

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn _changeset, invitation ->
{:ok, %Dreng.Accounts.User{}} =
Dreng.Accounts.manually_create_user(%{role: invitation.role, email: invitation.email},
authorize?: false
)
end)
end

@impl true
def atomic(changeset, _opts, _context) do
{:ok, changeset}
end
end
defmodule Dreng.Changes.CreateUserFromInvitation do
use Ash.Resource.Change

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn _changeset, invitation ->
{:ok, %Dreng.Accounts.User{}} =
Dreng.Accounts.manually_create_user(%{role: invitation.role, email: invitation.email},
authorize?: false
)
end)
end

@impl true
def atomic(changeset, _opts, _context) do
{:ok, changeset}
end
end
My understanding was that the return value of the after_action beccomes the return value of the whole action, but this test fails:
test "if invitation is valid, a user with matching attributes is created" do
invitation = generate(invitation())
assert {:ok, %Dreng.Accounts.User{} = user} = Dreng.Accounts.accept_invitation(invitation)
end
test "if invitation is valid, a user with matching attributes is created" do
invitation = generate(invitation())
assert {:ok, %Dreng.Accounts.User{} = user} = Dreng.Accounts.accept_invitation(invitation)
end
And in the stack trace it is because the returned struct is an Invitation. Is what I'm trying to do (a) possible, and (b) reasonable, or should I go about it in some other way?
Solution:
This seems to have done the trick: ```elixir defmodule Dreng.Changes.ValidateInvitation do use Ash.Resource.Change ...
GitHub
Update before_action docs to use force_change_attribute/2 by jakobs...
Using change_attribute/2 in a before_action hook triggers a warning that the changeset has already been validated, suggests a new pattern that isn't compatible with the before_action hook. ...
Jump to solution
5 Replies
sevenseacat
sevenseacat2mo ago
I don’t think it’s possible
EAJ
EAJOP2mo ago
So maybe take the invitation token as an argument to a :create action on the user instead and do the invitation lookup and validation in a before_action?
Rebecca Le
Rebecca Le2mo ago
that would work
Solution
EAJ
EAJ2mo ago
This seems to have done the trick:
defmodule Dreng.Changes.ValidateInvitation do
use Ash.Resource.Change

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.before_action(changeset, fn changeset ->
Ash.Changeset.get_argument(changeset, :token)
|> Dreng.Accounts.get_invitation_by_token()
|> case do
{:ok, invitation} -> validate_invitation(changeset, invitation)
{:error, _} -> Ash.Changeset.add_error(changeset, "missing or invalid invitation")
end
end)
end

defp validate_invitation(changeset, %Dreng.Accounts.Invitation{} = invitation) do
cond do
invitation.used_at != nil ->
Ash.Changeset.add_error(changeset, "invitation has already been used")

DateTime.compare(invitation.expires_at, DateTime.utc_now()) in [:lt, :eq] ->
Ash.Changeset.add_error(changeset, "invitation is expired")

true ->
changeset
|> Ash.Changeset.force_change_attribute(:email, invitation.email)
|> Ash.Changeset.force_change_attribute(:role, invitation.role)
end
end
end
defmodule Dreng.Changes.ValidateInvitation do
use Ash.Resource.Change

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.before_action(changeset, fn changeset ->
Ash.Changeset.get_argument(changeset, :token)
|> Dreng.Accounts.get_invitation_by_token()
|> case do
{:ok, invitation} -> validate_invitation(changeset, invitation)
{:error, _} -> Ash.Changeset.add_error(changeset, "missing or invalid invitation")
end
end)
end

defp validate_invitation(changeset, %Dreng.Accounts.Invitation{} = invitation) do
cond do
invitation.used_at != nil ->
Ash.Changeset.add_error(changeset, "invitation has already been used")

DateTime.compare(invitation.expires_at, DateTime.utc_now()) in [:lt, :eq] ->
Ash.Changeset.add_error(changeset, "invitation is expired")

true ->
changeset
|> Ash.Changeset.force_change_attribute(:email, invitation.email)
|> Ash.Changeset.force_change_attribute(:role, invitation.role)
end
end
end
Was a little surprised to see the warning when using change_attribute instead of force_change_attribute, so opened a PR with a suggestion of updating the docs as well: https://github.com/ash-project/ash/pull/2258
GitHub
Update before_action docs to use force_change_attribute/2 by jakobs...
Using change_attribute/2 in a before_action hook triggers a warning that the changeset has already been validated, suggests a new pattern that isn't compatible with the before_action hook. ...
sevenseacat
sevenseacat2mo ago
neat! thanks for clarifying that ❤️

Did you find this page helpful?