lib/runbox/scenario/user_action.ex

defmodule Runbox.Scenario.UserAction do
  @moduledoc group: :output_actions
  @moduledoc """
  Toolbox for working with scenario user actions.

  User action is action created by scenario via `Runbox.Scenario.UIAction` and fired
  by user (usually from UI). Scenario is usually also a receiver of fired action.
  Assumed workflow is following:

    1) Scenario creates list of actions (using `Runbox.Scenario.UIAction`) to be fired
       in the future and saves it somewhere (in incident asset for example).

    2) UI displays available actions.

    3) User fires selected action from UI.

    4) UI pushes the action to raw Kafka topic (defined in action) with extra data attached
       (usually some comment).

    5) Normalizer pipeline receives the action and sends it to target scenario.
  """

  defmodule Token do
    @moduledoc false
    use Joken.Config

    @impl true
    def token_config do
      # no default claims - UI Actions use JWT as verifiable envelope
      %{}
    end
  end

  alias __MODULE__
  alias Runbox.Message

  @typedoc """
  Fired user action.

  When action is fired by the user, it should be pushed to raw `topic` with current
  timestamp to be correctly reordered by `Normalizer`. Properties `type` and `details`
  should contain enough information for routing action to the target scenario. Property
  `user` is ID of user who fired the action, `data` contains extra information attached
  by environment where user fired the action (for example comment filled by the user on UI).
  """
  @type t :: %UserAction{type: String.t(), details: map, user: String.t(), data: any}
  @derive Jason.Encoder
  defstruct [:type, :details, :user, :data]

  # User actions do not use JWT for security reasons, so static secret is good enough.
  # If secret is changed, all saved user actions (JWTs) must be regenerated!
  @signer Joken.Signer.create("HS256", "Kqz0EyLw6HKcLRv222S6U3v1h6T")

  @doc """
  Packs user action to JWT to be used (fired) later (from UI for example).

  Parameter `details` must be a map encodable to JSON. Because `details` can be (and it is!)
  encoded/decoded to/from JSON during its life-cycle, atom property names of `details` are
  always converted to string property names in fired action's `details`.

  JWT is not used for security reasons - it is just good enough envelope protecting
  action against accidental corruption on its journey.
  """
  @spec pack(String.t(), String.t(), map) ::
          {:ok, Joken.bearer_token()} | {:error, Joken.error_reason()}
  def pack(topic, type, details) when is_binary(topic) and is_binary(type) and is_map(details) do
    claims = %{"topic" => topic, "type" => type, "details" => details}

    with {:ok, token, _} <- Token.generate_and_sign(claims, @signer) do
      {:ok, token}
    end
  end

  @doc """
  Unpacks action and makes it fired (prepared for sending to target topic).

  Routing properties (`type`, `details`) are propagated from packed action, `user` and `data`
  properties are attached to fired action. Target `topic` is also extracted from packed action.

  Returns error if packed action (JWT) is not valid.
  """
  @spec fire(Joken.bearer_token(), String.t(), any) ::
          {:ok, topic :: String.t(), String.t()} | {:error, Joken.error_reason()}
  def fire(packed, user, data) do
    with {:ok, claims} <- Token.verify_and_validate(packed, @signer),
         props = %{type: claims["type"], details: claims["details"], user: user, data: data},
         {:ok, action} <- Jason.encode(props) do
      {:ok, claims["topic"], action}
    end
  end

  @doc """
  Helper for UserAction forwarding to be used in normalizer.

  If helper is used in normalizer pipeline configuration in `config.ini` like

      [pipelines.some_pipeline]
      definition = Runbox.Scenario.UserAction.normalizer_pipeline_step

  then `Runbox.Message` `body` will contain fired `Runbox.Scenario.UserAction`.
  """
  @spec normalizer_pipeline_step(Message.t()) :: Message.t()
  def normalizer_pipeline_step(msg) do
    action = UserAction.parse_raw_action(msg.body)
    %{msg | body: action, type: action.type}
  end

  @doc """
  Parses string containing JSON to `Runbox.Scenario.UserAction`.
  """
  @spec parse_raw_action(String.t()) :: UserAction.t()
  def parse_raw_action(msg) do
    action = Jason.decode!(msg)

    %UserAction{
      type: Map.fetch!(action, "type"),
      user: Map.fetch!(action, "user"),
      data: Map.fetch!(action, "data"),
      details: Map.fetch!(action, "details")
    }
  end
end