lib/ash_authentication/add_ons/confirmation/confirmation_hook_change.ex

defmodule AshAuthentication.AddOn.Confirmation.ConfirmationHookChange do
  @moduledoc """
  Triggers a confirmation flow when one of the monitored fields is changed.

  Optionally inhibits changes to monitored fields on update.

  You can use this change in your actions where you want to send the user a
  confirmation (or inhibit changes after confirmation).  If you're not using one
  of the actions generated by the confirmation add-on then you'll need to
  manually pass the strategy name in the changeset context. Eg:

  ```elixir
  Changeset.new(user, %{})
  |> Changeset.set_context(%{strategy_name: :confirm})
  |> Changeset.for_update(:update, params)
  |> Accounts.update()
  ```

  or by adding it statically to your action definition:

  ```elixir
  update :change_email do
    change set_context(%{strategy_name: :confirm})
    change AshAuthentication.AddOn.Confirmation.ConfirmationHookChange
  end
  ```

  or by adding it as an option to the change definition:

  ```elixir
  update :change_email do
    change {AshAuthentication.AddOn.Confirmation.ConfirmationHookChange, strategy_name: :confirm}
  end
  ```
  """

  use Ash.Resource.Change
  alias Ash.{Changeset, Resource.Change}
  alias AshAuthentication.{AddOn.Confirmation, Info}

  @doc false
  @impl true
  @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
  def change(changeset, options, context) do
    case Info.find_strategy(changeset, context, options) do
      {:ok, strategy} ->
        do_change(changeset, strategy)

      :error ->
        changeset
    end
  end

  defp do_change(changeset, strategy) do
    changeset
    |> Changeset.before_action(fn changeset ->
      changeset
      |> not_confirm_action(strategy)
      |> should_confirm_action_type(strategy)
      |> monitored_field_changing(strategy)
      |> changes_would_be_valid()
      |> maybe_inhibit_updates(strategy)
      |> maybe_perform_confirmation(strategy, changeset)
    end)
  end

  defp not_confirm_action(%Changeset{} = changeset, strategy)
       when changeset.action != strategy.confirm_action_name,
       do: changeset

  defp not_confirm_action(_changeset, _strategy), do: nil

  defp should_confirm_action_type(%Changeset{} = changeset, strategy)
       when changeset.action_type == :create and strategy.confirm_on_create?,
       do: changeset

  defp should_confirm_action_type(%Changeset{} = changeset, strategy)
       when changeset.action_type == :update and strategy.confirm_on_update?,
       do: changeset

  defp should_confirm_action_type(_changeset, _strategy), do: nil

  defp monitored_field_changing(%Changeset{} = changeset, strategy) do
    if Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
      do: changeset,
      else: nil
  end

  defp monitored_field_changing(_changeset, _strategy), do: nil

  defp changes_would_be_valid(%Changeset{} = changeset) when changeset.valid?, do: changeset
  defp changes_would_be_valid(_), do: nil

  defp maybe_inhibit_updates(%Changeset{} = changeset, strategy)
       when changeset.action_type == :update and strategy.inhibit_updates? do
    strategy.monitor_fields
    |> Enum.reduce(changeset, &Changeset.clear_change(&2, &1))
  end

  defp maybe_inhibit_updates(changeset, _strategy), do: changeset

  defp maybe_perform_confirmation(%Changeset{} = changeset, strategy, original_changeset) do
    changeset
    |> nil_confirmed_at_field(strategy)
    |> Changeset.after_action(fn _changeset, user ->
      strategy
      |> Confirmation.confirmation_token(original_changeset, user)
      |> case do
        {:ok, token} ->
          {sender, send_opts} = strategy.sender
          sender.send(user, token, Keyword.put(send_opts, :changeset, original_changeset))

          metadata =
            user.__metadata__
            |> Map.put(:confirmation_token, token)

          {:ok, %{user | __metadata__: metadata}}

        _ ->
          {:ok, user}
      end
    end)
  end

  defp maybe_perform_confirmation(_changeset, _strategy, original_changeset),
    do: original_changeset

  defp nil_confirmed_at_field(changeset, strategy) do
    # If we're updating values, and we are inhibiting values on the changes (enforced at the call site)
    # then we want to reset the confirmed_at_field to `nil`
    # However, we do it `lazily` in case something they've done is already changing
    # the `confirmed_at`. This might happen in a social sign up where the email has
    # already been verified
    if changeset.action.type == :update do
      Changeset.force_change_new_attribute(changeset, strategy.confirmed_at_field, nil)
    else
      changeset
    end
  end
end