defmodule Runbox.Scenario.UserAction do
@moduledoc group: :output_actions
@moduledoc """
Toolbox for working with scenario user actions.
User action is action created by scenario and fired by user (usually from UI).
Scenario is usually also a receiver of fired action. Assumed workflow is following:
1) Scenario creates actions to be fired in the future and Altworx stores them.
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 empty).
5) Normalizer pipeline receives the action and sends it to target scenario via
a runtime topic.
Currently, user actions can be attached only to incidents. This can be done either
via the `user_actions` field of `Runbox.Scenario.OutputAction.Incident` and
`Runbox.Scenario.OutputAction.IncidentPatch` or using `Toolbox.Workflow`.
## Normalized user actions
Fired output actions are normalized into a `%Runbox.Message{}` with
`%Runbox.Scenario.UserAction{}` in its body. For example:
%Runbox.Message{
from: "N6_rt_camera_user_actions",
type: "close_incident",
timestamp: 1750857766412,
body: %Runbox.Scenario.UserAction{
type: "close_incident",
details: %{
"submit_text" => "Close the incident",
"type" => "button"
"incident_id" => "6157565f-8c9e-3e66-a5f6-2c4d6211a9c1",
},
user: "joe",
data: %{}
},
origin: %{offset: 2, partition: 0, topic: "camera_user_actions"}
}
"""
alias __MODULE__
alias Runbox.Master
alias Runbox.Message
@signer_env_key :user_action_signer
@doc false
def get_signer_env_key, do: @signer_env_key
@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]
@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 keys in `details` are always converted
to string keys in fired action's `details`.
`details` must contain `"type"` which is currently always `"button"` and `submit_text`
which will be used as the user-facing label for the button. Other fields are up to you.
Optionally, `access_tags` can be given inside `opts` to control access to the action.
A user must have all of the specified access tags to be able to fire the action.
If access tags are not given, the default `[]` is used, which means that anyone can
fire the action.
Note this function depends on a signer, that must be defined in an application environment. Signer
holds the secret keys and performs the signing process. A scenario can be executed or evaluated in
various environments and the signers also differ between these envs.
- Altworx
- when you run a scenario in a full Altworx instance
- this uses Altworx master node as a signer
- keys are secret, usually a part of the deployment configuration
- Sandbox
- when you run the scenario in a Sandbox, e.g. in tests or in Livebook.
- Sandbox automatically set's up a testing signer - `Runbox.Runtime.Sandbox.UserActionSigner`.
- The signer uses a fixed key good enough for testing purposes.
- The module also offers a function to validate and unpack the tokens, so you can assert them in
tests.
- No environment
- When you execute the code yourself directly, e.g. in a unit tests or in iex.
- You need to define a signer yourself. The simplest and preferred way is to use the Sandbox
signer. You can do so by adjusting the application env.
```
# config.exs
config :runbox, :user_action_signer, {Node.self(), Runbox.Runtime.Sandbox.UserActionSigner, :sign}
```
## Example
UserAction.pack("camera_user_actions", "close_incident", %{
"type" => "button",
"submit_text" => "Close the incident",
"incident_id" => "6157565f-8c9e-3e66-a5f6-2c4d6211a9c1"
}, access_tags: ["camera", "privileged"])
"""
@spec pack(String.t(), String.t(), map, access_tags: [String.t()]) ::
{:ok, Joken.bearer_token()} | {:error, Joken.error_reason()}
def pack(topic, type, details, opts \\ [])
when is_binary(topic) and is_binary(type) and is_map(details) and is_list(opts) do
{node, signer, fun} = Application.fetch_env!(:runbox, @signer_env_key)
access_tags = Keyword.get(opts, :access_tags, [])
claims = %{"topic" => topic, "type" => type, "details" => details, "access_tags" => access_tags}
with {:ok, token, _} <- Master.call(node, signer, fun, [claims]) do
{:ok, token}
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