AshDispatch.Event behaviour (AshDispatch v0.5.1)
View SourceBehaviour and DSL for defining events.
Events represent business occurrences that trigger notifications across multiple channels (email, in-app, Discord, etc.).
Usage
Define an event module:
defmodule MyApp.Events.Orders.Created do
use AshDispatch.Event
dispatch do
id "orders.created"
domain :orders
channels do
channel :in_app, :user, time: :immediate
channel :email, :user, time: 5.minutes(), skip_if_read: true
end
content do
subject "Your order has been created"
notification_title "Order created"
notification_message "Order #{{order_number}} has been created"
end
end
# Override for complex logic:
def prepare_template_assigns(context, channel) do
%{
order_number: format_order_id(context.data.order)
}
end
endCallbacks
All callbacks are optional with sensible defaults. Override only what you need:
Required (if not in DSL)
id/0- Event identifierchannels/1- List of delivery channels
Content
subject/2- Email subjectfrom/2- Email from addressnotification_title/2- In-app notification titlenotification_message/2- In-app notification message
Metadata
domain/0- Event domaincategory/1- Email preference categoryuser_configurable?/1- Can users opt-out?notification_type/1- Notification type (:info, :success, :warning, :error)
Advanced
recipients/2- Get recipients for channelprepare_template_assigns/2- Prepare template variablessample_data/0- Sample data for previewsgenerate_send_variables/2- Generate real data for sending (tokens, URLs, etc.)counters/2- Counters to broadcast
See behaviour documentation for complete list.
Summary
Callbacks
In-app notification action button label.
Whether this event requires user action.
In-app notification action URL.
Whether this event is applicable for a specific user.
Returns the email preference category for this event.
Returns the list of delivery channels for this event.
Counters to broadcast when this event creates notifications.
Returns the key to use for the resource in context.data.
Returns the domain this event belongs to.
Email from address as {name, email} tuple.
Generate real variables for actual event dispatch (manual triggers, etc.).
Returns the unique event identifier.
In-app notification message.
In-app notification title.
Notification type for UI styling.
Prepare additional template assigns.
Returns the recipients for a specific channel.
Required resources for manual trigger.
Returns the primary resource module for this event.
Sample data for event previews.
Returns the URL path to the source resource for this event.
Email subject line.
Whether users can opt-out of this event via email preferences.
Returns the event version (for future use).
Types
@type channel() :: AshDispatch.Channel.t()
@type context() :: AshDispatch.Context.t()
@type event_id() :: String.t()
@type recipient() :: map()
@type recipients() :: [recipient()]
Callbacks
In-app notification action button label.
Whether this event requires user action.
In-app notification action URL.
Whether this event is applicable for a specific user.
Used by manual trigger to filter events based on user state. For example, only show email confirmation event if user is not confirmed.
This callback is only used for filtering the manual trigger event list in admin UIs. It does NOT affect normal event dispatch.
Example
# Only show in manual trigger if user hasn't confirmed
def applicable_for_user?(user) do
is_nil(user.confirmed_at)
end
# Only show if user is archived
def applicable_for_user?(user) do
not is_nil(user.archived_at)
end
Returns the email preference category for this event.
Maps to UserEmailPreferences field. If nil, email is not user-configurable.
Returns the list of delivery channels for this event.
Example
def channels(_context) do
[
%Channel{transport: :in_app, audience: :user},
%Channel{transport: :email, audience: :user, time: {:in, 300}}
]
end
Counters to broadcast when this event creates notifications.
Return list of counter atoms for specific channel.
Example
def counters(_ctx, %Channel{transport: :in_app, audience: :user}) do
[:pending_orders, :cart_items]
end
def counters(_ctx, _channel), do: []
@callback data_key() :: atom()
Returns the key to use for the resource in context.data.
Defaults to deriving from the resource module name if not specified.
Example
def data_key, do: :user
@callback domain() :: atom()
Returns the domain this event belongs to.
Example
def domain, do: :orders
Email from address as {name, email} tuple.
Generate real variables for actual event dispatch (manual triggers, etc.).
This callback is called when actually sending an event, allowing events to
generate real data (tokens, URLs, etc.) instead of using sample data from
sample_data/0.
The callback receives:
context- The event context with loaded resource dataopts- The current variables map passed to dispatch
Returns:
{:ok, enhanced_opts}- Success with enhanced variables map{:error, reason}- Failure (will abort dispatch, not send with sample data)
When it's called
- Manual triggers: Always called when sending (not previewing)
- Normal dispatch: Only called if variables are missing
- Previews: Never called (uses
sample_data/0instead)
Security Note
IMPORTANT: For security-critical events (password reset, magic links, invitations),
always return {:error, reason} if token generation fails. Never send emails with
sample/fallback tokens - this creates a security vulnerability!
Example
For password reset events that need real JWT tokens:
def generate_send_variables(context, opts) do
user = context.data[:user]
# Only generate if not already provided
if user && not Map.has_key?(opts, :reset_token) do
case generate_password_reset_token(user) do
{:ok, token} ->
{:ok, Map.put(opts, :reset_token, token)}
{:error, reason} ->
# SECURITY: Fail dispatch instead of sending sample token!
{:error, "Failed to generate password reset token: #{inspect(reason)}"}
end
else
{:ok, opts}
end
end
defp generate_password_reset_token(user) do
AshAuthentication.Jwt.token_for_user(user, %{
purpose: :password_reset,
token_lifetime: {24, :hours}
})
endFor invitation events:
def generate_send_variables(context, opts) do
invited_user = context.data[:invited_user]
if invited_user && not Map.has_key?(opts, :invitation_token) do
case generate_invitation_token(invited_user) do
{:ok, token} ->
{:ok, Map.put(opts, :invitation_token, token)}
{:error, reason} ->
{:error, "Failed to generate invitation token: #{inspect(reason)}"}
end
else
{:ok, opts}
end
endBest practices
- Always check if variable already exists before generating
- Return
{:error, reason}on generation failure for security-critical data - Use this for secure tokens, dynamic URLs, or time-sensitive data
- Don't duplicate logic from normal action flows - they can provide variables directly
- Single Source of Truth: Keep token generation logic ONLY in this callback, not scattered across RPC actions, senders, or other entry points
@callback id() :: event_id()
Returns the unique event identifier.
Example
def id, do: "orders.created"
In-app notification message.
In-app notification title.
@callback notification_type(context()) :: :info | :success | :warning | :error
Notification type for UI styling.
Prepare additional template assigns.
Return a map that will be available in templates.
Example
def prepare_template_assigns(context, channel) do
%{
order_number: format_order_id(context.data.order),
order_url: build_url(context.data.order, channel)
}
end
@callback recipients(context(), channel()) :: recipients()
Returns the recipients for a specific channel.
Default implementation delegates to RecipientResolver.from_audience/2.
Required resources for manual trigger.
Declares which resources this event needs to render properly, along with optional Ash filters to restrict which records can be selected.
Used by manual trigger UIs to show resource selectors. For example, an "order processed" event needs an Order resource, and should only show orders with status: :processed.
Returns a keyword list where:
- Key is the data key (e.g., :order, :ticket)
- Value is either:
- Just the resource module:
ProductOrder - Tuple with module and filter:
{ProductOrder, filter: [status: :processed]}
- Just the resource module:
Examples
# Event needs an order, any status
def required_resources do
[order: Magasin.Orders.ProductOrder]
end
# Event needs a processed order only
def required_resources do
[order: {Magasin.Orders.ProductOrder, filter: [status: :processed]}]
end
# Event needs multiple resources
def required_resources do
[
order: {Magasin.Orders.ProductOrder, filter: [status: :completed]},
user: Magasin.Accounts.User
]
end
# Event needs no resources (just user context)
def required_resources do
[]
endThe filter keyword list is passed directly to Ash queries, supporting any valid Ash filter syntax.
@callback resource() :: module()
Returns the primary resource module for this event.
This is used by the manual trigger system to know what resource to load. Mirrors the DSL pattern where events are defined on a resource.
Example
def resource, do: MyApp.Accounts.User
@callback sample_data() :: map()
Sample data for event previews.
Return a map with sample data matching the expected event data structure.
Example
def sample_data do
%{
order: MyApp.Factory.build(MyApp.Orders.Order),
user: MyApp.Factory.build(MyApp.Accounts.User)
}
end
Returns the URL path to the source resource for this event.
This callback enables linking delivery receipts back to their source resources (e.g., linking an "order created" receipt to the order itself).
The callback receives:
context- The event context with loaded resource datachannel- The channel being dispatched to (allows audience-specific paths)
Returns:
String.t()- The URL path (e.g., "/admin/orders/uuid-here")nil- If no source URL is applicable
Pattern Matching on Audience
Different audiences typically have different paths to the same resource:
@impl true
def source_url(context, %{audience: :admin}) do
path = Application.get_env(:my_app, :app_paths)[:admin][:order]
String.replace(path, ":id", to_string(context.data.order.id))
end
def source_url(context, %{audience: :user}) do
path = Application.get_env(:my_app, :app_paths)[:user][:order]
String.replace(path, ":id", to_string(context.data.order.id))
end
def source_url(_context, _channel), do: nilUsage
The URL is computed at runtime via a calculation on DeliveryReceipt. This allows the same receipt to show different URLs based on who is viewing (admin vs user).
To load the source URL on a receipt:
receipt = Ash.load!(receipt, [:source_url])The calculation uses source_type and source_id (persisted on the receipt) to
look up the event module and call this callback.
Email subject line.
Can be overridden per-channel via pattern matching:
def subject(_ctx, %Channel{audience: :user}), do: "Your order"
def subject(_ctx, %Channel{audience: :admin}), do: "New order"
Whether users can opt-out of this event via email preferences.
@callback version() :: pos_integer()
Returns the event version (for future use).