Current section

Files

Jump to
ash_dispatch lib channel_resolver.ex
Raw

lib/channel_resolver.ex

defmodule AshDispatch.ChannelResolver do
  @moduledoc """
  Centralized channel resolution for AshDispatch events.

  Handles the logic for determining which channels an event should dispatch to,
  with consistent priority rules across all parts of the system.

  ## Priority Rules

  Currently: **DSL channels take precedence** over module callback channels.

  1. If DSL channels are defined (via `dispatch` DSL on resources), use those
  2. Otherwise, fall back to `channels/1` callback on the event module

  This can be changed in the future to merge both sources if needed.

  ## Usage

      # With event config from DSL introspection
      channels = ChannelResolver.resolve(event_id, event_module, context,
        dsl_channels: event_config.channels
      )

      # Without pre-loaded DSL channels (will look them up)
      channels = ChannelResolver.resolve(event_id, event_module, context)

      # Just check if event has email channels
      has_email? = ChannelResolver.has_transport?(event_id, event_module, context, :email)
  """

  alias AshDispatch.{Channel, Config, Context}

  require Logger

  @doc """
  Resolve channels for an event.

  ## Options

  - `:dsl_channels` - Pre-loaded DSL channel configs (avoids lookup)
  - `:strategy` - Resolution strategy: `:dsl_first` (default) or `:merge`

  ## Returns

  List of `%AshDispatch.Channel{}` structs.
  """
  @spec resolve(String.t() | nil, module() | nil, Context.t(), keyword()) :: [Channel.t()]
  def resolve(event_id, event_module, context, opts \\ []) do
    strategy = Keyword.get(opts, :strategy, :dsl_first)
    dsl_channels = get_dsl_channels(event_id, opts)
    module_channels = get_module_channels(event_module, context)

    case strategy do
      :dsl_first ->
        # DSL channels take precedence, module is fallback
        if Enum.any?(dsl_channels) do
          dsl_channels
        else
          module_channels
        end

      :merge ->
        # Merge both sources (future option)
        # Could deduplicate by transport+audience if needed
        dsl_channels ++ module_channels
    end
  end

  @doc """
  Check if an event has channels of a specific transport type.

  Useful for filtering events to only those with email channels, etc.
  """
  @spec has_transport?(String.t() | nil, module() | nil, Context.t(), atom(), keyword()) ::
          boolean()
  def has_transport?(event_id, event_module, context, transport, opts \\ []) do
    event_id
    |> resolve(event_module, context, opts)
    |> Enum.any?(&(&1.transport == transport))
  end

  @doc """
  Get channels from DSL configuration only.

  Looks up channels defined via the `dispatch` DSL on resources.
  """
  @spec get_dsl_channels(String.t() | nil, keyword()) :: [Channel.t()]
  def get_dsl_channels(nil, _opts), do: []

  def get_dsl_channels(event_id, opts) do
    # Use pre-loaded channels if provided
    case Keyword.fetch(opts, :dsl_channels) do
      {:ok, channels} when is_list(channels) ->
        Enum.map(channels, &to_channel_struct/1)

      _ ->
        # Look up from configured domains
        lookup_dsl_channels(event_id)
    end
  end

  @doc """
  Get channels from event module callback only.

  Calls the `channels/1` callback on the event module if it exists.
  """
  @spec get_module_channels(module() | nil, Context.t()) :: [Channel.t()]
  def get_module_channels(nil, _context), do: []

  def get_module_channels(event_module, context) do
    if function_exported?(event_module, :channels, 1) do
      try do
        event_module.channels(context)
      rescue
        error ->
          Logger.debug(
            "[ChannelResolver] Failed to get channels from #{inspect(event_module)}: #{inspect(error)}"
          )

          []
      end
    else
      []
    end
  end

  @doc """
  Convert various channel formats to a Channel struct.

  Handles:
  - `%AshDispatch.Channel{}` - returned as-is
  - `%AshDispatch.Dsl.Channel{}` - converted to runtime Channel
  - Maps with channel fields
  - Keyword lists with channel fields
  """
  @spec to_channel_struct(term()) :: Channel.t()
  def to_channel_struct(%Channel{} = channel), do: channel

  def to_channel_struct(%AshDispatch.Dsl.Channel{} = dsl_channel) do
    %Channel{
      transport: dsl_channel.transport,
      audience: dsl_channel.audience,
      time: dsl_channel.time || {:in, 0},
      policy: dsl_channel.policy || :always,
      variant: dsl_channel.variant,
      webhook_url: dsl_channel.webhook_url,
      deduplicate_group: dsl_channel.deduplicate_group,
      optional: dsl_channel.optional || false,
      exclude_actor: dsl_channel.exclude_actor || false,
      content: dsl_channel.content || %{},
      metadata: dsl_channel.metadata || %{},
      opts: dsl_channel.opts || %{},
      load: dsl_channel.load || []
    }
  end

  def to_channel_struct(channel) when is_map(channel) do
    %Channel{
      transport: Map.fetch!(channel, :transport),
      audience: Map.fetch!(channel, :audience),
      time: extract_time(channel),
      policy: Map.get(channel, :policy, :always),
      variant: Map.get(channel, :variant),
      webhook_url: Map.get(channel, :webhook_url),
      deduplicate_group: Map.get(channel, :deduplicate_group),
      optional: Map.get(channel, :optional, false),
      exclude_actor: Map.get(channel, :exclude_actor, false),
      content: Map.get(channel, :content, %{}) |> to_map(),
      metadata: Map.get(channel, :metadata, %{}) |> to_map(),
      opts: Map.get(channel, :opts, %{}),
      load: Map.get(channel, :load, [])
    }
  end

  def to_channel_struct(channel) when is_list(channel) do
    %Channel{
      transport: Keyword.fetch!(channel, :transport),
      audience: Keyword.fetch!(channel, :audience),
      time: extract_time(channel),
      policy: Keyword.get(channel, :policy, :always),
      variant: Keyword.get(channel, :variant),
      webhook_url: Keyword.get(channel, :webhook_url),
      deduplicate_group: Keyword.get(channel, :deduplicate_group),
      optional: Keyword.get(channel, :optional, false),
      exclude_actor: Keyword.get(channel, :exclude_actor, false),
      content: Keyword.get(channel, :content, []) |> to_map(),
      metadata: Keyword.get(channel, :metadata, []) |> to_map(),
      opts: Keyword.get(channel, :opts, %{}),
      load: Keyword.get(channel, :load, [])
    }
  end

  # Private helpers

  defp lookup_dsl_channels(event_id) do
    domains = Config.domains()

    result =
      Enum.find_value(domains, fn domain ->
        try do
          domain
          |> Ash.Domain.Info.resources()
          |> Enum.find_value(fn resource ->
            if AshDispatch.Resource.Info.dispatch_enabled?(resource) do
              resource
              |> AshDispatch.Resource.Info.events()
              |> Enum.find(fn event -> event.event_id == event_id end)
              |> case do
                nil -> nil
                event -> event.channels
              end
            else
              nil
            end
          end)
        rescue
          _ -> nil
        end
      end)

    case result do
      channels when is_list(channels) ->
        Enum.map(channels, &to_channel_struct/1)

      _ ->
        []
    end
  end

  defp extract_time(channel) when is_map(channel) do
    case Map.get(channel, :time) || Map.get(channel, :delay) do
      nil -> {:in, 0}
      {:in, _} = time -> time
      {:at, _} = time -> time
      seconds when is_integer(seconds) -> {:in, seconds}
      _ -> {:in, 0}
    end
  end

  defp extract_time(channel) when is_list(channel) do
    case Keyword.get(channel, :time) || Keyword.get(channel, :delay) do
      nil -> {:in, 0}
      {:in, _} = time -> time
      {:at, _} = time -> time
      seconds when is_integer(seconds) -> {:in, seconds}
      _ -> {:in, 0}
    end
  end

  defp to_map(value) when is_list(value), do: Enum.into(value, %{})
  defp to_map(value) when is_map(value), do: value
  defp to_map(_), do: %{}
end