Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
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