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