Packages

Event-driven notification system for Ash Framework with multiple transport types

Current section

Files

Jump to
ash_dispatch lib notifier dispatch_handler.ex
Raw

lib/notifier/dispatch_handler.ex

defmodule AshDispatch.Notifier.DispatchHandler do
@moduledoc """
Side-effect orchestration for dispatch events — extracted from the
prior `AshDispatch.Changes.DispatchEvent` module.
## Why this exists separately from the notifier
`AshDispatch.Notifier` is a thin adapter that pattern-matches the
notification's action and looks up per-action config. The actual
dispatch logic — event-module resolution, context building, channel
resolution, dispatcher invocation — lives here. This isolates the
"what to dispatch" decisions (Notifier) from the "how to dispatch"
orchestration (DispatchHandler).
## Two modes (preserved verbatim from the prior change module)
- **DSL-based** — driven by an `event :foo, ... end` block that the
transformer persisted as a config map with `:event_config`.
- **Standalone** — for events that bypass the DSL block and were
historically registered via `change DispatchEvent, event_id, data_key`.
Mosis has zero such callers today; the path is preserved for
AshDispatch substrate completeness.
"""
alias AshDispatch.{ChannelResolver, Config, Context, Dispatcher, EventResolver}
alias AshDispatch.Helpers.RecordReader
require Logger
@doc """
Dispatch a single event for a notification.
Takes the notification (an `%Ash.Notifier.Notification{}`) and the
per-action `event_config` persisted by `InjectDispatchChanges`.
Equivalent to the prior `dispatch_event/4` private function called
from the change's `after_action` hook.
"""
@spec dispatch(Ash.Notifier.Notification.t(), map()) :: :ok
def dispatch(%Ash.Notifier.Notification{} = notification, %{} = config) do
changeset = notification.changeset
record = notification.data
# The change-module-era input shape was {opts, ash_context}; reconstruct
# an equivalent ash_context from the notification.
ash_context = ash_context_from_notification(notification)
case Map.fetch(config, :event_config) do
{:ok, event_config} ->
dispatch_dsl_event(changeset, record, config, ash_context, event_config)
:error ->
dispatch_standalone_event(changeset, record, config, ash_context)
end
rescue
error ->
Logger.error("""
[AshDispatch.Notifier.DispatchHandler] dispatch failed for #{config[:event_id]}
Error: #{inspect(error)}
Stacktrace: #{Exception.format_stacktrace(__STACKTRACE__)}
""")
# Don't fail the notification — same posture as the prior change
# module (dispatch_event.ex:127-137).
:ok
end
# ── Mode 1: DSL-based event dispatch (preserved verbatim) ───────
defp dispatch_dsl_event(changeset, record, opts, ash_context, event_config) do
event_id = Map.fetch!(opts, :event_id)
load = Map.get(opts, :load, [])
data_key = Map.get(event_config, :data_key)
include_actor_as = Map.get(event_config, :include_actor_as)
# Always resolve module at runtime via EventResolver
# Compile-time resolution is unreliable due to module compilation order
# (event modules compile after resources, so Code.ensure_loaded? fails)
event_module =
case EventResolver.find_module(event_id) do
{:ok, m} ->
m
{:error, :not_found} ->
Logger.warning(
"[AshDispatch] No event module found for #{event_id} - callbacks (prepare_data, prepare_template_assigns) will not be called"
)
nil
end
record = maybe_load_relationships(record, load, changeset)
context =
build_context(
event_id,
record,
changeset,
ash_context,
data_key,
include_actor_as,
event_config
)
context = maybe_enrich_context_with_prepare_data(context, changeset, record, event_module)
context =
if event_module do
case EventResolver.generate_send_variables(
event_module,
context,
context.variables || %{}
) do
{:ok, enhanced_variables} ->
%{context | variables: enhanced_variables}
{:error, reason} ->
Logger.error(
"[AshDispatch] generate_send_variables failed for #{event_id}: #{inspect(reason)}"
)
context
end
else
context
end
channels = resolve_channels(context, event_config, event_module)
Enum.each(channels, fn channel ->
Dispatcher.dispatch_channel(context, channel, event_config)
end)
:ok
end
# ── Mode 2: Standalone event dispatch (preserved verbatim) ──────
defp dispatch_standalone_event(changeset, record, opts, _ash_context) do
event_id = Map.fetch!(opts, :event_id)
data_key = Map.fetch!(opts, :data_key)
load = Map.get(opts, :load, [])
record = maybe_load_relationships(record, load, changeset)
data =
case EventResolver.find_module(event_id) do
{:ok, event_module} ->
prepared_data = EventResolver.prepare_data(event_module, changeset, record)
if map_size(prepared_data) == 0 do
Map.put(%{}, data_key, record)
else
prepared_data
end
{:error, :not_found} ->
Map.put(%{}, data_key, record)
end
case Dispatcher.dispatch(event_id, data) do
{:ok, _result} ->
:ok
{:error, reason} ->
Logger.error("Failed to dispatch standalone event #{event_id}: #{inspect(reason)}")
:ok
end
end
# ── Helpers (preserved verbatim from dispatch_event.ex) ─────────
defp ash_context_from_notification(%Ash.Notifier.Notification{} = notification) do
# The change-module API received an Ash context map; the notification
# carries actor + tenant separately and the rest is in changeset.context.
base = (notification.changeset && notification.changeset.context) || %{}
Map.put(base, :actor, notification.actor)
end
defp maybe_load_relationships(record, [], _changeset), do: record
defp maybe_load_relationships(record, load, changeset) do
domain = (changeset && changeset.domain) || record.__struct__.__domain__()
case Ash.load(record, load, domain: domain, authorize?: false) do
{:ok, loaded_record} ->
loaded_record
{:error, error} ->
Logger.warning("""
Failed to load relationships #{inspect(load)} for event dispatch
Error: #{inspect(error)}
Continuing with unloaded record...
""")
record
end
end
defp build_context(
event_id,
record,
changeset,
ash_context,
data_key,
include_actor_as,
event_config
) do
resource_key = data_key || record.__struct__.__schema__(:source)
actor = Map.get(ash_context, :actor)
data =
%{resource_key => record, :actor => actor}
|> maybe_add_actor_alias(actor, include_actor_as)
locale_from = Map.get(event_config, :locale_from)
default_locale = Map.get(event_config, :default_locale) || Config.default_locale()
locale = extract_locale_from_record(record, locale_from, default_locale)
%Context{
event_id: event_id,
data: data,
resource_key: resource_key,
priority: Map.get(event_config, :priority, :standard),
user: actor,
source: :resource_action,
locale: locale,
base_url: get_base_url(),
now: DateTime.utc_now(),
metadata: %{
action: changeset.action.name,
action_type: changeset.action.type
}
}
end
defp extract_locale_from_record(record, locale_from, default_locale) do
# `RecordReader.safe_get/2` treats select-narrowed (`%Ash.NotLoaded{}`)
# fields as nil so the cond falls through to the next candidate rather
# than returning the sentinel. `Map.has_key?` checks whether the field
# exists on the struct at all (covers resources that don't define
# `:visitor_locale` or `:locale`).
cond do
locale_from && Map.has_key?(record, locale_from) && RecordReader.safe_get(record, locale_from) ->
RecordReader.safe_get(record, locale_from)
Map.has_key?(record, :visitor_locale) && RecordReader.safe_get(record, :visitor_locale) ->
RecordReader.safe_get(record, :visitor_locale)
Map.has_key?(record, :locale) && RecordReader.safe_get(record, :locale) ->
RecordReader.safe_get(record, :locale)
true ->
default_locale
end
end
defp maybe_add_actor_alias(data, _actor, nil), do: data
defp maybe_add_actor_alias(data, actor, alias_key), do: Map.put(data, alias_key, actor)
defp maybe_enrich_context_with_prepare_data(context, _changeset, _record, nil), do: context
defp maybe_enrich_context_with_prepare_data(context, changeset, record, event_module) do
prepared_data = EventResolver.prepare_data(event_module, changeset, record)
if map_size(prepared_data) > 0 do
%{context | data: Map.merge(context.data, prepared_data)}
else
context
end
end
defp get_base_url do
cond do
url = endpoint_url() ->
url
host = System.get_env("PHX_HOST") ->
scheme = System.get_env("PHX_SCHEME", "https")
port = System.get_env("PHX_PORT", "443")
case {scheme, port} do
{"https", "443"} -> "#{scheme}://#{host}"
{"http", "80"} -> "#{scheme}://#{host}"
_ -> "#{scheme}://#{host}:#{port}"
end
base_url = Config.base_url() ->
base_url
true ->
"http://localhost:4000"
end
end
# Defensive wrapper around `endpoint.url/0`. The Phoenix endpoint
# caches its config in `:persistent_term`; when the endpoint module
# is configured (e.g. via `config :ash_dispatch, endpoint: …`) but
# `Endpoint.start_link/1` has not been called (mix-task BEAM,
# release tasks, retrospective harnesses), `Phoenix.Config.cache/3`
# raises `RuntimeError: could not find persistent term for endpoint
# … Make sure your endpoint is started`.
#
# Returning nil here lets the cond in `get_base_url/0` fall through
# to PHX_HOST / Config.base_url() / localhost without surfacing the
# raise as an [error] log on every dispatched event. The dispatch
# itself was already non-fatal (the outer rescue swallowed it), but
# the noise polluted every retrospective stdout.
defp endpoint_url do
case Config.endpoint() do
nil ->
nil
endpoint ->
try do
endpoint.url()
rescue
_ -> nil
end
end
end
defp resolve_channels(context, event_config, event_module) do
ChannelResolver.resolve(
context.event_id,
event_module,
context,
dsl_channels: Map.get(event_config, :channels)
)
end
end