Current section

Files

Jump to
ash_dispatch lib event_resolver.ex
Raw

lib/event_resolver.ex

defmodule AshDispatch.EventResolver do
  @moduledoc """
  Centralized event resolution and callback execution for AshDispatch events.

  Handles the logic for finding event modules and safely calling their callbacks
  with consistent error handling.

  ## Finding Events

      # Find an event module by ID
      {:ok, module} = EventResolver.find_module("orders.created")

      # Get all registered events
      events = EventResolver.all_events()

  ## Safe Callback Execution

  All callback functions handle errors gracefully and return sensible defaults:

      # Get sample data with fallback to empty map
      data = EventResolver.sample_data(module)

      # Get subject with fallback
      subject = EventResolver.subject(module, context, channel, default: "No subject")

      # Check if callback exists and call it
      {:ok, result} = EventResolver.call_if_exported(module, :custom_callback, [arg1, arg2])

  ## Context Building

      # Build a sample context for previews
      context = EventResolver.build_sample_context(event_id, module)
  """

  alias AshDispatch.Context

  require Logger

  # ===========================================================================
  # Event Discovery
  # ===========================================================================

  @doc """
  Find an event module by its event ID.

  ## Returns

  - `{:ok, module}` if found
  - `{:error, :not_found}` if no event with that ID exists
  """
  @spec find_module(String.t()) :: {:ok, module()} | {:error, :not_found}
  def find_module(event_id) do
    case Enum.find(all_events(), fn {id, _module} -> id == event_id end) do
      {^event_id, module} -> {:ok, module}
      nil -> {:error, :not_found}
    end
  end

  @doc """
  Get all registered event modules as a list of `{event_id, module}` tuples.

  Uses the EventRegistry which auto-discovers events from configured domains.
  """
  @spec all_events() :: [{String.t(), module()}]
  def all_events do
    AshDispatch.EventRegistry.get_event_modules()
  end

  @doc """
  Get the event ID from a module, if it implements `id/0`.
  """
  @spec event_id(module()) :: String.t() | nil
  def event_id(module) do
    call_if_exported(module, :id, [], default: nil)
  end

  # ===========================================================================
  # Context Building
  # ===========================================================================

  @doc """
  Build a sample context for previews and testing.

  Uses the module's `sample_data/0` callback if available.
  """
  @spec build_sample_context(String.t(), module()) :: Context.t()
  def build_sample_context(event_id, module) do
    sample_data = sample_data(module)

    %Context{
      event_id: event_id,
      data: sample_data,
      metadata: %{}
    }
  end

  # ===========================================================================
  # Safe Callback Execution
  # ===========================================================================

  @doc """
  Call a function on the module if it's exported, with error handling.

  ## Options

  - `:default` - Value to return if function not exported or raises (default: nil)

  ## Examples

      # Returns result or default
      value = EventResolver.call_if_exported(module, :domain, [], default: :unknown)

      # With arguments
      subject = EventResolver.call_if_exported(module, :subject, [context, channel])
  """
  @spec call_if_exported(module(), atom(), list(), keyword()) :: any()
  def call_if_exported(module, function, args, opts \\ []) do
    default = Keyword.get(opts, :default, nil)
    arity = length(args)

    # Ensure module is loaded before checking exports
    if Code.ensure_loaded?(module) && function_exported?(module, function, arity) do
      try do
        apply(module, function, args)
      rescue
        error ->
          Logger.debug(
            "[EventResolver] #{inspect(module)}.#{function}/#{arity} raised: #{inspect(error)}"
          )

          default
      end
    else
      default
    end
  end

  @doc """
  Check if a module exports a specific function.
  """
  @spec exports?(module(), atom(), non_neg_integer()) :: boolean()
  def exports?(module, function, arity) do
    Code.ensure_loaded?(module) && function_exported?(module, function, arity)
  end

  # ===========================================================================
  # Common Callback Helpers
  # ===========================================================================

  @doc """
  Get sample data from an event module.
  """
  @spec sample_data(module()) :: map()
  def sample_data(module) do
    call_if_exported(module, :sample_data, [], default: %{})
  end

  @doc """
  Get the domain from an event module.
  """
  @spec domain(module()) :: atom() | nil
  def domain(module) do
    call_if_exported(module, :domain, [], default: nil)
  end

  @doc """
  Get the resource from an event module.
  """
  @spec resource(module()) :: module() | nil
  def resource(module) do
    call_if_exported(module, :resource, [], default: nil)
  end

  @doc """
  Get the data key from an event module.
  """
  @spec data_key(module()) :: atom() | nil
  def data_key(module) do
    call_if_exported(module, :data_key, [], default: nil)
  end

  @doc """
  Check if the event is user configurable.
  """
  @spec user_configurable?(module(), Context.t()) :: boolean()
  def user_configurable?(module, context) do
    call_if_exported(module, :user_configurable?, [context], default: false)
  end

  @doc """
  Get the event category for preferences.
  """
  @spec category(module(), Context.t()) :: atom() | nil
  def category(module, context) do
    call_if_exported(module, :category, [context], default: nil)
  end

  @doc """
  Check if the event is applicable for a specific user.
  """
  @spec applicable_for_user?(module(), any()) :: boolean()
  def applicable_for_user?(module, user) do
    call_if_exported(module, :applicable_for_user?, [user], default: true)
  end

  @doc """
  Get the email subject for a channel.

  Falls back to DSL content from the registry if the module returns the default value.
  """
  @spec subject(module(), Context.t(), AshDispatch.Channel.t(), keyword()) :: String.t() | nil
  def subject(module, context, channel, opts \\ []) do
    default = Keyword.get(opts, :default, nil)
    result = call_if_exported(module, :subject, [context, channel], default: default)

    # If module returned the static default "Notification", try to get from DSL registry
    if result == "Notification" || result == default do
      case get_dsl_content(context.event_id, :subject) do
        nil ->
          result

        dsl_subject ->
          AshDispatch.Event.Interpolation.interpolate(dsl_subject, context, channel, module)
      end
    else
      result
    end
  end

  @doc """
  Get the from address for a channel.
  """
  @spec from(module(), Context.t(), AshDispatch.Channel.t()) :: {String.t(), String.t()} | nil
  def from(module, context, channel) do
    call_if_exported(module, :from, [context, channel], default: nil)
  end

  @doc """
  Get the template variant for a channel.
  """
  @spec template_variant(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def template_variant(module, context, channel) do
    call_if_exported(module, :template_variant, [context, channel], default: nil)
  end

  @doc """
  Prepare template assigns for a channel.
  """
  @spec prepare_template_assigns(module(), Context.t(), AshDispatch.Channel.t()) :: map()
  def prepare_template_assigns(module, context, channel) do
    call_if_exported(module, :prepare_template_assigns, [context, channel], default: %{})
  end

  @doc """
  Get recipients for a channel.
  """
  @spec recipients(module(), Context.t(), AshDispatch.Channel.t()) :: [String.t()]
  def recipients(module, context, channel) do
    call_if_exported(module, :recipients, [context, channel], default: [])
  end

  @doc """
  Get notification title for in-app notifications.

  Falls back to DSL content from the registry if the module returns the default value.
  """
  @spec notification_title(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def notification_title(module, context, channel) do
    result = call_if_exported(module, :notification_title, [context, channel], default: nil)

    # If module returned the static default, try to get from DSL registry
    if result == "Notification" || result == nil do
      case get_dsl_content(context.event_id, :notification_title) do
        nil ->
          result

        dsl_title ->
          AshDispatch.Event.Interpolation.interpolate(dsl_title, context, channel, module)
      end
    else
      result
    end
  end

  @doc """
  Get notification message for in-app notifications.

  Falls back to DSL content from the registry if the module returns the default value.
  """
  @spec notification_message(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def notification_message(module, context, channel) do
    result = call_if_exported(module, :notification_message, [context, channel], default: nil)

    # If module returned the static default, try to get from DSL registry
    if result == "You have a new notification" || result == nil do
      case get_dsl_content(context.event_id, :notification_message) do
        nil ->
          result

        dsl_message ->
          AshDispatch.Event.Interpolation.interpolate(dsl_message, context, channel, module)
      end
    else
      result
    end
  end

  @doc """
  Get required resources for manual trigger.
  """
  @spec required_resources(module()) :: keyword()
  def required_resources(module) do
    call_if_exported(module, :required_resources, [], default: [])
  end

  @doc """
  Generate send variables for actual delivery.
  """
  @spec generate_send_variables(module(), Context.t(), map()) ::
          {:ok, map()} | {:error, term()}
  def generate_send_variables(module, context, variables) do
    if exports?(module, :generate_send_variables, 2) do
      try do
        module.generate_send_variables(context, variables)
      rescue
        error ->
          Logger.error(
            "[EventResolver] #{inspect(module)}.generate_send_variables/2 raised: #{inspect(error)}"
          )

          {:error, error}
      end
    else
      {:ok, variables}
    end
  end

  @doc """
  Prepare data from changeset (for resource-triggered events).
  """
  @spec prepare_data(module(), Ash.Changeset.t(), any()) :: map()
  def prepare_data(module, changeset, record) do
    call_if_exported(module, :prepare_data, [changeset, record], default: %{})
  end

  @doc """
  Get the notification type from an event module.
  """
  @spec notification_type(module(), Context.t()) :: atom()
  def notification_type(module, context) do
    call_if_exported(module, :notification_type, [context], default: :info)
  end

  @doc """
  Check if the event requires action from the user.
  """
  @spec action_required?(module(), Context.t()) :: boolean()
  def action_required?(module, context) do
    call_if_exported(module, :action_required?, [context], default: false)
  end

  @doc """
  Get action URL for in-app notifications.
  """
  @spec action_url(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def action_url(module, context, channel) do
    call_if_exported(module, :action_url, [context, channel], default: nil)
  end

  @doc """
  Get action label for in-app notifications.
  """
  @spec action_label(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def action_label(module, context, channel) do
    call_if_exported(module, :action_label, [context, channel], default: nil)
  end

  @doc """
  Get HTML body for email (for test modules without templates).
  Returns nil if callback not exported.
  """
  @spec body_html(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def body_html(module, context, channel) do
    call_if_exported(module, :body_html, [context, channel], default: nil)
  end

  @doc """
  Get text body for email (for test modules without templates).
  Returns nil if callback not exported.
  """
  @spec body_text(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def body_text(module, context, channel) do
    call_if_exported(module, :body_text, [context, channel], default: nil)
  end

  @doc """
  Get email attachments for a channel via the optional `attachments/2` callback.

  Returns a list of attachment maps (each with `:filename`, `:content_type`,
  `:data`). Events without the callback → `[]`.
  """
  @spec attachments(module(), Context.t(), AshDispatch.Channel.t()) :: [map()]
  def attachments(module, context, channel) do
    call_if_exported(module, :attachments, [context, channel], default: [])
  end

  @doc """
  Check if module exports both body_html and body_text callbacks.
  Used to determine if we should use callbacks instead of templates.
  """
  @spec has_body_callbacks?(module()) :: boolean()
  def has_body_callbacks?(module) do
    exports?(module, :body_html, 2) && exports?(module, :body_text, 2)
  end

  @doc """
  Get counters for broadcasting.
  """
  @spec counters(module(), Context.t(), AshDispatch.Channel.t()) :: [atom()]
  def counters(module, context, channel) do
    call_if_exported(module, :counters, [context, channel], default: [])
  end

  @doc """
  Get source URL for linking receipts to source resources.
  """
  @spec source_url(module(), Context.t(), AshDispatch.Channel.t()) :: String.t() | nil
  def source_url(module, context, channel) do
    call_if_exported(module, :source_url, [context, channel], default: nil)
  end

  # ===========================================================================
  # Private Helpers
  # ===========================================================================

  # Look up DSL content field from the event registry
  defp get_dsl_content(event_id, field) when is_binary(event_id) and is_atom(field) do
    case AshDispatch.EventRegistry.find_event(event_id) do
      {:ok, %{content: content}} when is_map(content) ->
        Map.get(content, field)

      {:ok, %{content: content}} when is_list(content) ->
        Keyword.get(content, field)

      _ ->
        nil
    end
  end

  defp get_dsl_content(_, _), do: nil
end