lib/runbox/scenario/notification.ex

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