Packages

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

Current section

Files

Jump to
ash_dispatch lib introspection.ex
Raw

lib/introspection.ex

defmodule AshDispatch.Introspection do
@moduledoc """
Central introspection module for discovering all AshDispatch events.
Provides a unified view of events from both inline DSL (defined in resources)
and module-based events (standalone event modules).
## Usage
# Get all events in the application
events = AshDispatch.Introspection.all_events(:my_app)
# Get resources with dispatch enabled
resources = AshDispatch.Introspection.dispatch_resources(:my_app)
# Find missing templates
missing = AshDispatch.Introspection.all_missing_templates(:my_app)
"""
alias AshDispatch.Naming
alias AshDispatch.Resource.Info, as: ResourceInfo
@type event_info :: %{
source: :inline | :module,
event_id: String.t(),
name: atom() | nil,
resource: module() | nil,
module: module() | nil,
domain: atom() | nil,
channels: list(map()),
content: map() | nil,
metadata: map() | nil
}
@type missing_template :: %{
event_id: String.t(),
path: String.t(),
filename: String.t(),
format: :html | :text,
transport: atom(),
variant: atom() | nil,
locale: String.t() | nil
}
# ============================================================================
# Event Discovery
# ============================================================================
@doc """
Returns all events across all dispatch-enabled resources and event modules.
Combines inline DSL events (from resources) and module-based events into
a unified list.
## Parameters
- `otp_app` - The OTP application name
## Returns
List of event info maps with unified structure
"""
@spec all_events(atom()) :: [event_info()]
def all_events(otp_app) do
# Events are discovered from inline DSL in resources
# Each inline event may reference an event module for callbacks
discover_inline_events(otp_app)
end
@doc """
Returns all resources that have the AshDispatch.Resource extension.
## Parameters
- `otp_app` - The OTP application name
## Returns
List of resource modules with dispatch enabled
"""
@spec dispatch_resources(atom()) :: [module()]
def dispatch_resources(otp_app) do
otp_app
|> domains()
|> Enum.flat_map(&safe_resources/1)
|> Enum.filter(&ResourceInfo.dispatch_enabled?/1)
end
# ============================================================================
# Template Detection
# ============================================================================
@doc """
Finds all missing templates for events in the application.
Checks inline DSL events for missing template files based on their
channel definitions.
## Parameters
- `otp_app` - The OTP application name
## Returns
List of missing template info maps
"""
@spec all_missing_templates(atom()) :: [missing_template()]
def all_missing_templates(otp_app) do
otp_app
|> all_events()
|> Enum.flat_map(&missing_templates(&1, otp_app))
end
@doc """
Calculates required templates for an event based on its channels.
## Parameters
- `event_info` - Event info map from `all_events/1`
## Returns
List of required template specs
"""
@spec required_templates(event_info()) :: [map()]
def required_templates(event_info) do
event_info.channels
|> Enum.flat_map(&templates_for_channel/1)
|> Enum.uniq_by(& &1.filename)
end
@doc """
Finds missing templates for a specific event.
## Parameters
- `event_info` - Event info map
- `otp_app` - The OTP application name
## Returns
List of missing template info maps
"""
@spec missing_templates(event_info(), atom()) :: [missing_template()]
def missing_templates(event_info, otp_app) do
template_dir = template_directory(event_info, otp_app)
event_info
|> required_templates()
|> Enum.filter(fn template ->
path = Path.join(template_dir, template.filename)
not File.exists?(path)
end)
|> Enum.map(fn template ->
%{
event_id: event_info.event_id,
path: Path.join(template_dir, template.filename),
filename: template.filename,
format: template.format,
transport: template.transport,
variant: template.variant,
locale: template[:locale]
}
end)
end
@doc """
Returns the template directory path for an event.
Delegates to `AshDispatch.TemplateResolver.resolve_template_directory/2` which is
the single source of truth for template path resolution.
## Resolution Order
1. **Explicit module**: If event has `module:` option, use module-based path
2. **Derived module**: Use `{App}.{Domain}.Events.{EventName}.Event` path
Events are expected to have modules (either explicit or generated via `mix ash_dispatch.gen`).
Templates live in `{module_dir}/templates/`.
## Parameters
- `event_info` - Event info map
- `otp_app` - The OTP application name
## Returns
Path string to the template directory
"""
@spec template_directory(event_info(), atom()) :: String.t()
def template_directory(event_info, otp_app) do
AshDispatch.TemplateResolver.resolve_template_directory(event_info, otp_app)
end
# ============================================================================
# Event Module Detection
# ============================================================================
@doc """
Finds missing event modules for inline DSL events.
Returns modules for ALL events that don't have a user-provided `module:` option.
Generated modules provide:
- Template directory for email/sms transports
- Fallback callbacks (recipients, content, etc.)
- A place to add custom logic later
The system uses a **hybrid approach**: DSL configuration takes precedence,
but the generated module provides fallbacks for anything not specified in DSL.
## Parameters
- `otp_app` - The OTP application name
## Returns
List of missing module info maps
"""
@spec missing_event_modules(atom()) :: [map()]
def missing_event_modules(otp_app) do
otp_app
|> all_events()
|> Enum.filter(&(&1.source == :inline))
# Skip events that already have a user-provided module (override)
|> Enum.reject(&(&1.module != nil))
|> Enum.map(fn event_info ->
module_name = derive_module_name(event_info, otp_app)
module_path = module_path_from_name(module_name, otp_app)
%{
event_id: event_info.event_id,
module_name: module_name,
module_path: module_path,
exists: Code.ensure_loaded?(module_name),
event_info: event_info
}
end)
|> Enum.reject(& &1.exists)
end
@doc """
Derives the expected module name for an event.
Uses otp_app as the module prefix (e.g., :my_app → MyApp) and derives
the domain from either the event_info or the resource.
Used by both the generator (to create modules) and the transformer
(to auto-connect generated modules).
## Parameters
- `event_info` - Event info map or struct with :domain, :name, and :resource keys
- `otp_app` - The OTP application name (used for module prefix)
## Returns
Module name atom (e.g., MyApp.Orders.Events.Created.Event)
"""
@spec derive_module_name(map(), atom()) :: module()
def derive_module_name(event_info, otp_app) do
domain = event_info[:domain] || Naming.domain_name(event_info[:resource])
Naming.event_module_from_otp_app(otp_app, domain, event_info[:name])
end
# ============================================================================
# Private Functions
# ============================================================================
defp discover_inline_events(otp_app) do
otp_app
|> dispatch_resources()
|> Enum.flat_map(fn resource ->
resource
|> ResourceInfo.events()
# Filter to only Event structs — the dispatch section may also contain
# EntityChanges, ResourceMeta, AudiencePrefix, etc.
|> Enum.filter(&match?(%AshDispatch.Resource.Dsl.Event{}, &1))
|> Enum.map(&normalize_inline_event(&1, resource, otp_app))
end)
end
defp normalize_inline_event(event, resource, otp_app) do
# Use Naming for consistent derivation
resource_name = Naming.resource_name(resource)
event_id = event.event_id || Naming.event_id(resource, event.name)
domain = Naming.domain_name(resource)
# Get resource-level locales configuration
resource_locales = get_resource_locales(resource)
# Merge locales: channel > event > resource (channel locales handled in normalize_channels)
event_locales = event.locales || resource_locales[:locales] || []
%{
source: :inline,
event_id: event_id,
name: event.name,
resource: resource,
resource_name: resource_name,
module: event.module,
domain: domain && String.to_atom(domain),
channels: normalize_channels(event.channels || [], event_locales),
content: event.content,
metadata: event.metadata,
invalidates: event.invalidates || [],
trigger_on: event.trigger_on,
data_key: event.data_key,
locales: event_locales,
otp_app: otp_app
}
end
# Get resource-level locale configuration
defp get_resource_locales(resource) do
# events/1 returns all entities in the dispatch section (events, locales, audience_prefix, etc.)
case ResourceInfo.events(resource) do
entities when is_list(entities) ->
Enum.find_value(entities, %{}, fn
%AshDispatch.Resource.Dsl.Locales{} = locales ->
%{
locales: locales.locales,
default_locale: locales.default_locale,
locale_from: locales.locale_from
}
_ ->
nil
end)
_ ->
%{}
end
rescue
_ -> %{}
end
defp normalize_channels(channels, event_locales) when is_list(channels) do
Enum.map(channels, fn channel ->
case channel do
%{transport: _} = ch ->
# Channel-level locales take priority over event-level
channel_locales = Map.get(ch, :locales, [])
effective_locales = if channel_locales != [], do: channel_locales, else: event_locales
ch
|> Map.take([:transport, :audience, :variant, :policy, :content, :metadata, :locale])
|> Map.put(:locales, effective_locales)
[_ | _] = opts ->
# Channel-level locales take priority over event-level
channel_locales = Keyword.get(opts, :locales, [])
effective_locales = if channel_locales != [], do: channel_locales, else: event_locales
%{
transport: Keyword.get(opts, :transport),
audience: Keyword.get(opts, :audience),
variant: Keyword.get(opts, :variant),
policy: Keyword.get(opts, :policy),
content: Keyword.get(opts, :content),
metadata: Keyword.get(opts, :metadata),
locale: Keyword.get(opts, :locale),
locales: effective_locales
}
_ ->
%{transport: nil, audience: nil, variant: nil, locales: event_locales}
end
end)
end
defp normalize_channels(_, _), do: []
defp templates_for_channel(%{transport: transport, variant: variant, locales: locales})
when is_list(locales) and locales != [] do
# Generate templates for each locale
# Templates are named: email.{locale}.html.heex, email.{variant}.{locale}.html.heex
base_templates = templates_for_transport(transport, variant)
# For each locale, generate locale-specific template specs
locale_templates =
Enum.flat_map(locales, fn locale ->
locale_str = to_string(locale)
Enum.map(base_templates, fn template ->
# Insert locale before the format extension
# email.html.heex -> email.en.html.heex
# email.admin.html.heex -> email.admin.en.html.heex
locale_filename = insert_locale_in_filename(template.filename, locale_str)
%{template | filename: locale_filename, locale: locale}
end)
end)
# Return both base templates (fallback) and locale-specific templates
base_templates ++ locale_templates
end
defp templates_for_channel(%{transport: transport, variant: variant}) do
templates_for_transport(transport, variant)
end
defp templates_for_channel(_), do: []
# Generate base templates for a transport (without locale)
defp templates_for_transport(transport, variant) do
# Templates are determined by transport type and variant presence:
# - If variant is set: ONLY variant-specific templates (e.g., email.admin.html.heex)
# - If no variant: base templates (e.g., email.html.heex)
case {transport, variant} do
{:email, nil} ->
# No variant - need base templates
[
%{
transport: :email,
format: :html,
filename: "email.html.heex",
variant: nil,
locale: nil
},
%{
transport: :email,
format: :text,
filename: "email.text.eex",
variant: nil,
locale: nil
}
]
{:email, variant} when not is_nil(variant) ->
# Has variant - only need variant-specific templates
variant_str = to_string(variant)
[
%{
transport: :email,
format: :html,
filename: "email.#{variant_str}.html.heex",
variant: variant,
locale: nil
},
%{
transport: :email,
format: :text,
filename: "email.#{variant_str}.text.eex",
variant: variant,
locale: nil
}
]
{:sms, _} ->
[%{transport: :sms, format: :text, filename: "sms.text.eex", variant: nil, locale: nil}]
# in_app, webhook, discord, slack don't require templates
_ ->
[]
end
end
# Insert locale into filename before the format extension
# email.html.heex -> email.en.html.heex
# email.admin.html.heex -> email.admin.en.html.heex
# sms.text.eex -> sms.en.text.eex
defp insert_locale_in_filename(filename, locale) do
case String.split(filename, ".") do
# email.html.heex -> [email, html, heex]
[base, format, ext] ->
"#{base}.#{locale}.#{format}.#{ext}"
# email.admin.html.heex -> [email, admin, html, heex]
[base, variant, format, ext] ->
"#{base}.#{variant}.#{locale}.#{format}.#{ext}"
# Unexpected format - just append locale
parts ->
Enum.join([List.first(parts), locale | Enum.drop(parts, 1)], ".")
end
end
defp module_path_from_name(module_name, otp_app) do
parts =
module_name
|> Module.split()
|> Enum.map(&Macro.underscore/1)
# Replace app prefix with lib/{app}
[_app | rest] = parts
Path.join(["lib", to_string(otp_app) | rest]) <> ".ex"
end
defp domains(otp_app) do
Application.get_env(otp_app, :ash_domains, [])
end
defp safe_resources(domain) do
if Code.ensure_loaded?(domain) do
case Ash.Domain.Info.resources(domain) do
resources when is_list(resources) -> resources
_ -> []
end
else
[]
end
rescue
_ -> []
end
end