defmodule Runbox.Scenario.Notification do
@moduledoc group: :internal
@moduledoc """
Module contains functions for working with scenario notifications.
"""
@notification_subfolder "notifications"
@overrides_subfolder "./settings/scenario_notification_overrides"
@template_subfolder "templates"
@type template() :: %{
notification_type: String.t(),
template_type: String.t(),
channel: String.t(),
language: String.t(),
filename: String.t(),
defined_in: :scenario | :deployment,
absolute_path: Path.t()
}
@doc """
Lists all template files defined for a particular scenario.
It returns both templates from the scenario and overrides from the deployment. The source can be
discriminated using the `:defined_in` key.
"""
@spec list_templates(String.t()) :: [template()]
def list_templates(scenario_id) do
default_subfolder = default_templates_base_folder(scenario_id)
override_subfolder = overrides_base_folder(scenario_id)
sources = [scenario: default_subfolder, deployment: override_subfolder]
for {source, base_folder} <- sources,
absolute_path <- Path.wildcard("#{base_folder}/*/*/*/*/*.eex") do
relative_path = Path.relative_to(absolute_path, base_folder)
[notification_type, template_type, channel, language, filename] = Path.split(relative_path)
%{
notification_type: notification_type,
template_type: template_type,
channel: channel,
language: language,
filename: filename,
defined_in: source,
absolute_path: absolute_path
}
end
end
@doc """
Lists all notification types for a scenario.
"""
@spec list_notification_types(String.t()) :: {:ok, [String.t()]}
def list_notification_types(scenario_id) do
default_subfolder = default_templates_base_folder(scenario_id)
override_subfolder = overrides_base_folder(scenario_id)
get(default_subfolder, override_subfolder)
end
@doc """
Lists all template types of the specified notification.
"""
@spec list_template_types(String.t(), String.t()) :: {:ok, [String.t()]}
def list_template_types(scenario_id, notification_type) do
default_subfolder =
Path.join([default_templates_base_folder(scenario_id), to_string(notification_type)])
override_subfolder =
Path.join([overrides_base_folder(scenario_id), to_string(notification_type)])
get(default_subfolder, override_subfolder)
end
@doc """
Lists all channels with templates defined for scenario, notification type and template type.
"""
@spec list_channels(String.t(), String.t(), String.t()) :: {:ok, [String.t()]}
def list_channels(scenario_id, notification_type, template_type) do
default_subfolder =
Path.join([
default_templates_base_folder(scenario_id),
to_string(notification_type),
to_string(template_type)
])
override_subfolder =
Path.join([
overrides_base_folder(scenario_id),
to_string(notification_type),
to_string(template_type)
])
get(default_subfolder, override_subfolder)
end
@doc """
Lists all template languages defined for scenario, notification type, template type and channel.
"""
@spec list_languages(String.t(), String.t(), String.t(), String.t()) :: {:ok, [String.t()]}
def list_languages(scenario_id, notification_type, template_type, channel) do
default_subfolder =
Path.join([
default_templates_base_folder(scenario_id),
to_string(notification_type),
to_string(template_type),
to_string(channel)
])
override_subfolder =
Path.join([
overrides_base_folder(scenario_id),
to_string(notification_type),
to_string(template_type),
to_string(channel)
])
get(default_subfolder, override_subfolder)
end
@doc """
Loads and evaluates templates specified by scenario, notification type, template type, channel
and language.
If language is not defined or does not exist, `fallback_language` is used.
"""
@spec load_and_eval_templates(
scenario_id :: String.t(),
notification_type :: String.t(),
template_type :: String.t(),
channel :: String.t(),
language :: String.t(),
data :: keyword(),
fallback_language :: String.t()
) :: {:ok, %{String.t() => String.t()}} | {:error, atom(), String.t()}
def load_and_eval_templates(
scenario,
notification_type,
template_type,
channel,
language,
data,
fallback_language
) do
with {:ok, templates} <-
load_templates(
scenario,
notification_type,
template_type,
channel,
language,
fallback_language
) do
{:ok, Enum.into(templates, %{}, fn {name, fun} -> {name, fun.(data)} end)}
end
end
defp load_templates(
scenario,
notification_type,
template_type,
channel,
language,
fallback_language
) do
overrides_folder =
Path.join([
overrides_base_folder(scenario),
template_subfolder(notification_type, template_type, channel, language)
])
overrides_fallback_folder =
if fallback_language do
Path.join([
overrides_base_folder(scenario),
template_subfolder(notification_type, template_type, channel, fallback_language)
])
end
default_folder =
Path.join([
default_templates_base_folder(scenario),
template_subfolder(notification_type, template_type, channel, language)
])
default_fallback_folder =
if fallback_language do
Path.join([
default_templates_base_folder(scenario),
template_subfolder(notification_type, template_type, channel, fallback_language)
])
end
[overrides_folder, overrides_fallback_folder, default_folder, default_fallback_folder]
|> Enum.reject(&is_nil/1)
|> Enum.reduce_while(
nil,
fn folder, _ ->
case compile_templates(folder) do
{:ok, _template_map} = res ->
{:halt, res}
{:error, _, _} = error ->
{:cont, error}
end
end
)
end
# finds all template files in the `folder` and compiles them.
# if any template fails then the whole compilation fails; also fails if no templates are found
defp compile_templates(folder) do
{templates, errors} =
folder
|> ls()
|> Enum.filter(&String.ends_with?(&1, ".eex"))
|> Enum.map(fn file ->
{template_name(file), compile_template(Path.join([folder, file]))}
end)
|> Enum.split_with(&match?({_, {:ok, _}}, &1))
case {templates, errors} do
{[], []} -> {:error, :no_templates_found, folder}
{_, []} -> {:ok, Enum.into(templates, %{}, fn {name, {:ok, fun}} -> {name, fun} end)}
{_, [{_name, error} | _rest]} -> error
end
end
defp compile_template(file) do
compiled = EEx.compile_file(file)
{:ok, fn bindings -> elem(Code.eval_quoted(compiled, bindings, file: file), 0) end}
rescue
_ in File.Error -> {:error, :not_found, file}
_ in EEx.SyntaxError -> {:error, :bad_syntax, file}
end
defp default_templates_base_folder(scenario_id) do
Path.join(scenario_notifications_path(scenario_id), @template_subfolder)
end
defp overrides_base_folder(scenario_id) do
path = Runbox.Utils.Path.resolve_path(@overrides_subfolder)
Path.join([path, scenario_id, @template_subfolder])
end
defp template_subfolder(notification_type, template_type, channel, language) do
Path.join([
to_string(notification_type),
to_string(template_type),
to_string(channel),
to_string(language)
])
end
defp scenario_notifications_path(scenario_id) do
scenario_app = Application.fetch_env!(:runbox, :scenario_app)
Path.join([:code.priv_dir(scenario_app), @notification_subfolder, to_string(scenario_id)])
end
# lists all files and dirs in a path
defp ls(path) do
case File.ls(path) do
{:ok, files} -> files
_ -> []
end
end
# extract template name from filename (i.e. "content.eex")
defp template_name(file) do
String.slice(file, 0..-5//1)
end
defp get(default_subfolder, override_subfolder) do
defaults = ls(default_subfolder)
overrides = ls(override_subfolder)
{:ok,
defaults
|> Enum.concat(overrides)
|> Enum.uniq()}
end
end