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.
  """

  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, _opts, _context) do
    case Info.strategy(changeset.resource, :confirm) 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