defmodule Rulestead.Governance.ChangeRequest do
@moduledoc false
# Canonical governed mutation contract for approvals-first workflows.
alias Rulestead.Governance.ApprovalRequirement
@states [:submitted, :approved, :rejected, :cancelled, :executed]
@terminal_states [:rejected, :cancelled, :executed]
@governed_actions [
:publish_ruleset,
:advance_rollout,
:engage_kill_switch,
:release_kill_switch,
:promote_environment,
:apply_audience_mutation
]
@enforce_keys [
:state,
:action,
:environment_key,
:resource_type,
:resource_key,
:submitted_by,
:command,
:approval_requirement,
:correlation_id,
:metadata
]
defstruct [
:id,
:state,
:action,
:environment_key,
:resource_type,
:resource_key,
:submitted_by,
:command,
:approval_requirement,
:correlation_id,
:metadata
]
@type state :: :submitted | :approved | :rejected | :cancelled | :executed
@type action ::
:publish_ruleset
| :advance_rollout
| :engage_kill_switch
| :release_kill_switch
| :promote_environment
| :apply_audience_mutation
@type actor_summary :: %{
optional(:id) => String.t(),
optional(:type) => String.t(),
optional(:display) => String.t()
}
@type t :: %__MODULE__{
id: String.t() | nil,
state: state(),
action: action(),
environment_key: String.t() | nil,
resource_type: String.t() | nil,
resource_key: String.t() | nil,
submitted_by: actor_summary(),
command: map(),
approval_requirement: ApprovalRequirement.t(),
correlation_id: String.t() | nil,
metadata: map()
}
@spec new(t() | map() | keyword()) :: t()
def new(%__MODULE__{} = change_request), do: change_request
def new(attrs) when is_list(attrs) or is_map(attrs) do
attrs = Map.new(attrs)
%__MODULE__{
id: normalize_string(Map.get(attrs, :id)),
state: normalize_state(Map.get(attrs, :state)),
action: normalize_action(Map.get(attrs, :action)),
environment_key: normalize_string(Map.get(attrs, :environment_key)),
resource_type: normalize_string(Map.get(attrs, :resource_type)),
resource_key: normalize_string(Map.get(attrs, :resource_key)),
submitted_by: normalize_actor_summary(Map.get(attrs, :submitted_by)),
command: normalize_command(Map.get(attrs, :command)),
approval_requirement: ApprovalRequirement.new(Map.get(attrs, :approval_requirement, %{})),
correlation_id: normalize_string(Map.get(attrs, :correlation_id)),
metadata: normalize_map(Map.get(attrs, :metadata, %{}))
}
end
@spec states() :: [state()]
def states, do: @states
@spec terminal_states() :: [state()]
def terminal_states, do: @terminal_states
@spec governed_actions() :: [action()]
def governed_actions, do: @governed_actions
@spec serialize(t() | map() | keyword()) :: map()
def serialize(change_request) do
change_request = new(change_request)
%{
state: change_request.state,
action: change_request.action,
environment_key: change_request.environment_key,
resource_type: change_request.resource_type,
resource_key: change_request.resource_key,
submitted_by: change_request.submitted_by,
command: change_request.command,
approval_requirement: ApprovalRequirement.serialize(change_request.approval_requirement),
correlation_id: change_request.correlation_id,
metadata: change_request.metadata
}
end
defp normalize_state(state) when state in @states, do: state
defp normalize_state(_state), do: :submitted
defp normalize_action(action) when action in @governed_actions, do: action
defp normalize_action(_action), do: hd(@governed_actions)
defp normalize_actor_summary(actor) when is_map(actor) do
%{}
|> maybe_put(:id, normalize_string(Map.get(actor, :id) || Map.get(actor, "id")))
|> maybe_put(:type, normalize_string(Map.get(actor, :type) || Map.get(actor, "type")))
|> maybe_put(
:display,
normalize_string(Map.get(actor, :display) || Map.get(actor, "display"))
)
end
defp normalize_actor_summary(_actor), do: %{}
defp normalize_command(command) when is_map(command), do: normalize_map(command)
defp normalize_command(_command), do: %{}
defp normalize_map(map) when is_map(map) do
Map.new(map, fn
{key, value} when is_map(value) -> {to_string(key), normalize_map(value)}
{key, value} when is_list(value) -> {to_string(key), Enum.map(value, &normalize_value/1)}
{key, value} -> {to_string(key), normalize_value(value)}
end)
end
defp normalize_value(value) when is_map(value), do: normalize_map(value)
defp normalize_value(value) when is_list(value), do: Enum.map(value, &normalize_value/1)
defp normalize_value(value)
when is_nil(value) or is_boolean(value) or is_integer(value) or is_float(value) or
is_binary(value),
do: value
defp normalize_value(value) when is_atom(value), do: Atom.to_string(value)
defp normalize_value(_value), do: nil
defp normalize_string(value) when is_binary(value) do
value
|> String.trim()
|> case do
"" -> nil
normalized -> normalized
end
end
defp normalize_string(value) when is_atom(value),
do: value |> Atom.to_string() |> normalize_string()
defp normalize_string(_value), do: nil
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end