lib/runbox/scenario/notification.ex

defmodule Runbox.Scenario.Notification do
  @moduledoc """
  Module contains functions for working with scenario notifications.
  """

  @spec_path Path.join(["spec.exs"])

  @doc """
  Tries to load the notification spec of the given scenario.

  Returns
   * `{:ok, notification_spec}` if the spec is found
   * `{:error, :cannot_load, exception}` if the spec file cannot be found
   * `{:error, :bad_syntax, exception}` if the spec file was found but contains syntactic errors
  """
  @spec load_spec(String.t()) :: {:ok, any} | {:error, atom(), any}
  def load_spec(scenario_id) do
    spec_path = Path.join(scenario_notifications_path(scenario_id), @spec_path)

    try do
      {specs, _} = Code.eval_file(spec_path)
      {:ok, specs}
    rescue
      e in Code.LoadError -> {:error, :cannot_load, e}
      e in SyntaxError -> {:error, :bad_syntax, e}
    end
  end

  @doc """
  Lists all notification types for a scenario.
  """
  @spec list_notification_types(String.t()) :: {:ok, [String.t()]} | {:error, File.posix()}
  def list_notification_types(scenario_id) do
    scenario_id
    |> template_path()
    |> ls()
  end

  @doc """
  Lists all template types of the specified notification.
  """
  @spec list_template_types(String.t(), String.t()) :: {:ok, [String.t()]} | {:error, File.posix()}
  def list_template_types(scenario_id, notification_type) do
    [template_path(scenario_id), to_string(notification_type)]
    |> Path.join()
    |> ls()
  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()]} | {:error, File.posix()}
  def list_channels(scenario_id, notification_type, template_type) do
    [template_path(scenario_id), to_string(notification_type), to_string(template_type)]
    |> Path.join()
    |> ls()
  end

  @doc """
  Lists all template languages defined for senario, notification type, template type and channel.
  """
  @spec list_languages(String.t(), String.t(), String.t(), String.t()) ::
          {:ok, [String.t()]} | {:error, File.posix()}
  def list_languages(scenario_id, notification_type, template_type, channel) do
    [
      template_path(scenario_id),
      to_string(notification_type),
      to_string(template_type),
      to_string(channel)
    ]
    |> Path.join()
    |> ls()
  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
    folder =
      Path.join([
        template_path(scenario),
        to_string(notification_type),
        to_string(template_type),
        to_string(channel),
        to_string(language)
      ])

    fallback_folder =
      if fallback_language do
        Path.join([
          template_path(scenario),
          to_string(notification_type),
          to_string(template_type),
          to_string(channel),
          to_string(fallback_language)
        ])
      end

    case compile_templates(folder) do
      {:ok, _template_map} = res ->
        res

      {:error, _, _} = error ->
        if fallback_folder do
          compile_templates(fallback_folder)
        else
          error
        end
    end
  end

  # finds all template files in the `folder` and compiles them.
  # if any template failes then the whole compile fails, also fails if no templates are found
  defp compile_templates(folder) do
    {templates, errors} =
      folder
      |> ls_if_exists()
      |> 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 template_path(scenario_id) do
    Path.join(scenario_notifications_path(scenario_id), "templates")
  end

  defp scenario_notifications_path(scenario_id) do
    scenario_app = Application.fetch_env!(:runbox, :scenario_app)
    Path.join([:code.priv_dir(scenario_app), "notifications", to_string(scenario_id)])
  end

  # lists all files and dirs in a path
  defp ls(path) do
    if File.dir?(path) do
      File.ls(path)
    else
      {:ok, []}
    end
  end

  defp ls_if_exists(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)
  end
end