defmodule AshDispatch.Dispatcher do @moduledoc """ Core dispatcher that handles event delivery across all transports. This module orchestrates the dispatch process: 1. Creates DeliveryReceipt with full content 2. Routes to appropriate transport handler 3. Updates receipt status based on delivery result ## Receipt-First Pattern All deliveries follow the receipt-first pattern: - Receipt created BEFORE delivery attempt - Full content stored in receipt (enables retries without re-rendering) - Status tracked through lifecycle: pending → scheduled/sent → failed ## Transport Routing - `:in_app` → `AshDispatch.Transports.InApp` (synchronous) - `:email` → `AshDispatch.Transports.Email` (async via Oban) - `:discord` → `AshDispatch.Transports.Discord` (async via Oban) - `:slack` → `AshDispatch.Transports.Slack` (async via Oban) - `:sms` → `AshDispatch.Transports.SMS` (async via Oban) - `:webhook` → `AshDispatch.Transports.Webhook` (async via Oban) """ alias AshDispatch.Context alias AshDispatch.Transports alias AshDispatch.Resources.DeliveryReceipt alias AshDispatch.Event.RecipientExtractor require Logger @doc """ Dispatches an event by ID with data and variables. This is the high-level dispatch function that applications use. It looks up the event module, creates channels, and dispatches to all of them. ## Parameters - `event_id` - The event identifier (e.g., "requests.new_reseller_request") - `data` - Map of domain data (resources, users, etc.) - `variables` - Map of template variables (tokens, simple values, etc.) - defaults to %{} ## Returns - `{:ok, results}` - List of delivery receipt results - `{:error, reason}` - If event not found or dispatch fails ## Data vs Variables Use `data` for: - Domain resources (User, Order, Ticket, etc.) - Structured Ash resources - Objects that need relationship traversal Use `variables` for: - Authentication tokens (reset_token, confirmation_token) - Simple template values - Computed values that don't need relationships This separation prevents naming conflicts and makes the intent clearer. ## Examples # Dispatch with authentication token AshDispatch.Dispatcher.dispatch( "accounts.password_reset", %{user: user}, %{reset_token: token} ) # Dispatch with invitation data AshDispatch.Dispatcher.dispatch( "accounts.invited", %{invited_user: user, invited_by: admin}, %{invitation_token: token, custom_message: message} ) # Dispatch an order created event (no variables needed) AshDispatch.Dispatcher.dispatch( "orders.created", %{order: order, user: user} ) """ # Function head with default parameter def dispatch(event_id, data, variables \\ %{}) def dispatch(event_id, data, variables) when is_binary(event_id) and is_map(data) and is_map(variables) do # Get event module from app config event_modules = Application.get_env(:ash_dispatch, :event_modules, []) case Enum.find(event_modules, fn {id, _module} -> id == event_id end) do {^event_id, event_module} -> # Create initial context (before generate_send_variables) initial_context = %Context{ event_id: event_id, data: data, variables: variables, user: extract_user_from_data(data) } # Generate real variables if event module implements generate_send_variables/2 # This allows events to provide real data (tokens, etc.) for actual sending # while using sample_data() for previews enhanced_vars_result = if function_exported?(event_module, :generate_send_variables, 2) do event_module.generate_send_variables(initial_context, variables) else {:ok, variables} end case enhanced_vars_result do {:ok, enhanced_variables} -> # Update context with enhanced variables context = %{initial_context | variables: enhanced_variables} # Get channels from event module channels = event_module.channels(context) # Build event config event_config = %{ module: event_module } # Dispatch to all channels results = Enum.map(channels, fn channel -> dispatch_channel(context, channel, event_config) end) # Return success if any dispatch succeeded if Enum.any?(results, fn {:ok, _} -> true _ -> false end) do {:ok, results} else {:error, :all_dispatches_failed} end {:error, reason} -> Logger.error("generate_send_variables failed for #{event_id}: #{inspect(reason)}") {:error, reason} end nil -> Logger.error("Event module not found for event_id: #{event_id}") {:error, :event_not_found} end end @doc """ Low-level dispatch function that dispatches to a specific channel with a pre-built context. This is used internally and by DSL-based events that build their own context. Most applications should use the high-level `dispatch/2` or `dispatch/3` functions instead. ## Parameters - `context` - Pre-built AshDispatch.Context struct - `channel` - AshDispatch.Channel struct - `event_config` - Event configuration map (with :module, :content, etc.) ## Returns - `{:ok, receipt}` - DeliveryReceipt if successful - `{:error, reason}` - If dispatch fails """ def dispatch_channel(context, channel, event_config) do # Apply channel-level load (additional to event-level load) context = apply_channel_load(context, channel) # Resolve recipients for this channel recipients = resolve_recipients_for_channel(context, channel, event_config) # Create one receipt per recipient results = Enum.map(recipients, fn recipient -> dispatch_to_recipient(context, channel, event_config, recipient) end) # Return success if at least one dispatch succeeded successful = Enum.filter(results, fn {:ok, _} -> true _ -> false end) if length(successful) > 0 do {:ok, Enum.map(successful, fn {:ok, receipt} -> receipt end)} else {:error, :all_dispatches_failed} end end # Private function that dispatches to a specific recipient defp dispatch_to_recipient(context, channel, event_config, recipient) do case create_receipt(context, channel, event_config, recipient) do {:ok, receipt} -> case dispatch_to_transport(receipt, context, channel, event_config) do {:ok, updated_receipt} -> Logger.debug(""" Event dispatched successfully Event: #{context.event_id} Transport: #{channel.transport} Audience: #{channel.audience} Recipient: #{inspect(recipient)} Receipt ID: #{updated_receipt.id} Status: #{updated_receipt.status} """) # Broadcast counters if configured (once per recipient) broadcast_counters(context, channel, event_config) {:ok, updated_receipt} {:error, error} -> {:error, error} end {:error, error} -> {:error, error} end end # Private functions defp resolve_recipients_for_channel(context, channel, event_config) do # Hybrid mode: prefer inline DSL config over module callbacks for recipient resolution # This allows inline DSL to override module behavior cond do # If there's a recipient_filter in event_config, use it (inline DSL or event-level config) not is_nil(event_config[:recipient_filter]) -> AshDispatch.Event.Helpers.resolve_recipients_for_audience(context, channel, event_config) # If there's a module but no inline recipient config, use module callback not is_nil(event_config[:module]) -> module = event_config[:module] module.recipients(context, channel) # Pure inline DSL without recipient_filter - use helpers with app-level config true -> AshDispatch.Event.Helpers.resolve_recipients_for_audience(context, channel, event_config) end end defp create_receipt(context, channel, event_config, recipient) do # Build full content for the receipt content = build_receipt_content(context, channel, event_config) # Extract recipient identifier and name using configured fields recipient_identifier = extract_recipient_identifier(recipient, channel, event_config) recipient_name = extract_recipient_name(recipient, channel, event_config) recipient_user_id = get_user_id(recipient) # Always link email receipts to in-app notifications for the same user/event # This enables skip_if_read policy and provides useful linking for analytics notification_id = if channel.transport == :email and recipient_user_id do find_in_app_notification_id(context.event_id, recipient_user_id) else nil end # Extract source resource info for linking (e.g., link receipt to order) {source_type, source_id} = extract_source_info(context, event_config) # Build receipt attributes attrs = %{ event_id: context.event_id, transport: channel.transport, audience: channel.audience, recipient: recipient_identifier, user_id: recipient_user_id, notification_id: notification_id, source_type: source_type, source_id: source_id # Note: scheduled_for removed - Oban handles scheduling via schedule_in parameter } # Add recipient name to content if available content = if recipient_name do Map.put(content, :recipient_name, recipient_name) else content end # Add transport-specific content fields attrs = case channel.transport do :email -> Map.merge(attrs, %{ subject: content[:subject], body_html: content[:html_body], body_text: content[:text_body], content: content }) _ -> Map.put(attrs, :content, content) end # Create DeliveryReceipt # Use authorize?: false and skip_unknown_inputs to work within transaction context DeliveryReceipt |> Ash.Changeset.for_create(:create, attrs) |> Ash.create(authorize?: false, skip_unknown_inputs: [:notification_id]) end # Extract recipient identifier using RecipientExtractor with cascading config defp extract_recipient_identifier(recipient, channel, event_config) do RecipientExtractor.extract_identifier( recipient, channel.transport, channel.audience, event_config[:recipient] ) rescue error -> Logger.error(""" Failed to extract recipient identifier Transport: #{channel.transport} Audience: #{channel.audience} Recipient: #{inspect(recipient)} Error: #{inspect(error)} """) reraise error, __STACKTRACE__ end # Extract recipient name using RecipientExtractor (returns nil if not configured) defp extract_recipient_name(recipient, channel, event_config) do RecipientExtractor.extract_name( recipient, channel.transport, channel.audience, event_config[:recipient] ) rescue error -> Logger.warning(""" Failed to extract recipient name (continuing with nil) Transport: #{channel.transport} Audience: #{channel.audience} Recipient: #{inspect(recipient)} Error: #{inspect(error)} """) nil end # Get user_id from recipient struct defp get_user_id(recipient) when is_map(recipient) do Map.get(recipient, :id) end defp get_user_id(_), do: nil # Extract source resource info (type and ID) for linking receipts to source resources # Returns {source_type, source_id} tuple where values may be nil defp extract_source_info(context, event_config) do module = event_config[:module] cond do # Module-based events: use resource() callback to get the source type not is_nil(module) and function_exported?(module, :resource, 0) -> resource_module = module.resource() resource_key = get_resource_key(module, context) case Map.get(context.data, resource_key) do %{id: id} = _record when not is_nil(id) -> {to_string(resource_module), id} _ -> {to_string(resource_module), nil} end # Inline DSL events: use resource_module from event_config if present not is_nil(event_config[:resource_module]) -> resource_module = event_config[:resource_module] resource_key = context.resource_key case Map.get(context.data, resource_key) do %{id: id} = _record when not is_nil(id) -> {to_string(resource_module), id} _ -> {to_string(resource_module), nil} end # No source info available true -> {nil, nil} end end # Get resource key from module callback or context defp get_resource_key(module, context) do if function_exported?(module, :data_key, 0) do module.data_key() else context.resource_key end end # Find the in-app notification ID for skip_if_read policy defp find_in_app_notification_id(event_id, user_id) do require Ash.Query case AshDispatch.Resources.DeliveryReceipt |> Ash.Query.filter( event_id == ^event_id and user_id == ^user_id and transport == :in_app ) |> Ash.Query.select([:notification_id]) |> Ash.Query.limit(1) |> Ash.read(authorize?: false) do {:ok, [receipt | _]} -> receipt.notification_id _ -> nil end end defp build_receipt_content(context, channel, event_config) do base_content = case event_config[:module] do nil -> # Pure inline DSL - use inline content only build_inline_content(context, channel, event_config) module -> # Hybrid mode: module + inline DSL # Step 1: Enhance context with module's prepare_template_assigns if available enhanced_context = if function_exported?(module, :prepare_template_assigns, 2) do extra_assigns = safe_prepare_template_assigns(module, context, channel) # Add extra assigns to context variables Map.update(context, :variables, extra_assigns, fn vars -> Map.merge(vars, extra_assigns) end) else context end # Step 2: Build module content (with enhanced context) module_content = build_module_content(enhanced_context, channel, module, event_config) # Step 3: Check if there's inline DSL content to merge if has_inline_content?(event_config, channel) do # Build inline content with enhanced context (so interpolation has access to module assigns) inline_content = build_inline_content(enhanced_context, channel, event_config) # Inline DSL takes precedence over module callbacks Map.merge(module_content, inline_content) else # No inline DSL, use module content only module_content end end # Add policy to content if present on channel if channel.policy do Map.put(base_content, :policy, to_string(channel.policy)) else base_content end end # Check if there's inline DSL content configuration defp has_inline_content?(event_config, channel) do # Check if channel or event has content/metadata defined (channel.content && map_size(channel.content) > 0) || (channel.metadata && map_size(channel.metadata) > 0) || (event_config[:content] && length(event_config[:content]) > 0) || (event_config[:metadata] && length(event_config[:metadata]) > 0) end defp build_inline_content(context, channel, event_config) do # Prefer channel-level content/metadata, fall back to event-level # This allows both patterns: # 1. Transport-specific: channels: [[transport: :email, content: [subject: "..."]]] # 2. Shared: content: [subject: "..."], channels: [[transport: :email]] channel_content = channel.content || %{} channel_metadata = channel.metadata || %{} event_content = (event_config[:content] || %{}) |> Enum.into(%{}) event_metadata = (event_config[:metadata] || %{}) |> Enum.into(%{}) # Merge with channel-level taking precedence content_config = Map.merge(event_content, channel_content) metadata_config = Map.merge(event_metadata, channel_metadata) base_content = %{ transport: channel.transport, audience: channel.audience } # Add transport-specific content with variable interpolation transport_content = case channel.transport do :email -> # Try to render templates (convention-based or explicit path) {html_body, text_body} = render_inline_email_templates(context, channel, event_config) # Build base content with required fields base = %{ from: content_config[:from_email] || default_from_email() } # Add optional fields only if they have values (to not overwrite module callbacks in hybrid mode) base |> maybe_put(:subject, interpolate(content_config[:subject], context)) |> maybe_put(:html_body, html_body) |> maybe_put(:text_body, text_body) :in_app -> # Build base content with required fields base = %{ notification_type: metadata_config[:notification_type] || :info } # Add optional fields only if they have values (to not overwrite module callbacks in hybrid mode) base |> maybe_put( :title, interpolate(content_config[:title] || content_config[:notification_title], context) ) |> maybe_put( :message, interpolate( content_config[:message] || content_config[:notification_message], context ) ) |> maybe_put(:action_url, interpolate(content_config[:action_url], context)) |> maybe_put(:action_label, content_config[:action_label]) :discord -> %{ message: interpolate( content_config[:message] || content_config[:notification_message], context ), webhook_url: channel.webhook_url } :slack -> %{ message: interpolate( content_config[:message] || content_config[:notification_message], context ), webhook_url: channel.webhook_url } :sms -> %{ message: interpolate( content_config[:message] || content_config[:notification_message], context ) } :webhook -> %{ payload: content_config[:webhook_payload] || %{}, webhook_url: channel.webhook_url } end Map.merge(base_content, transport_content) end # Render email templates for inline DSL events defp render_inline_email_templates(context, channel, event_config) do # Get template configuration template_path = event_config[:template_path] event_id = context.event_id otp_app = get_otp_app(context) # Domain name for template path resolution domain = event_config[:domain] # Resource name for template path resolution resource_name = event_config[:resource_name] variant = channel.variant # Prepare template assigns assigns = Context.template_assigns(context) # Try to render HTML template html = case AshDispatch.TemplateResolver.render( template_path: template_path, event_id: event_id, otp_app: otp_app, # Pass domain for correct path derivation domain: domain, # Pass resource_name for collision prevention resource_name: resource_name, format: :html, transport: :email, variant: variant, assigns: assigns ) do {:ok, rendered} -> rendered {:error, :template_not_found} -> nil {:error, error} -> Logger.warning("Failed to render HTML template for #{event_id}: #{inspect(error)}") nil end # Try to render text template text = case AshDispatch.TemplateResolver.render( template_path: template_path, event_id: event_id, otp_app: otp_app, # Pass domain for correct path derivation domain: domain, # Pass resource_name for collision prevention resource_name: resource_name, format: :text, transport: :email, variant: variant, assigns: assigns ) do {:ok, rendered} -> rendered {:error, :template_not_found} -> nil {:error, error} -> Logger.warning("Failed to render text template for #{event_id}: #{inspect(error)}") nil end {html, text} end # Get OTP app name from context or fallback to default defp get_otp_app(context) do # Try to extract from resource_module if available (for module-based events) resource_module = Map.get(context, :resource_module) if resource_module do case Atom.to_string(resource_module) do "Elixir." <> rest -> rest |> String.split(".") |> List.first() |> String.downcase() |> String.to_atom() _ -> :ash_dispatch end else # For inline DSL events, derive from event_id # e.g., "requests.new_reseller_request" -> extract "Magasin" from data key derive_otp_app_from_event_id(context.event_id) end end # Derive OTP app from event_id for inline DSL events defp derive_otp_app_from_event_id(event_id) when is_binary(event_id) do # For now, try common app names based on event_id domain # Format: "domain.event_name" -> check data for module namespace case String.split(event_id, ".", parts: 2) do [domain, _event_name] -> # Try to guess from common domains # This is a heuristic - in production you might want to configure this case domain do domain when domain in ["requests", "orders", "tickets", "accounts"] -> :magasin "test_product" -> :ash_dispatch # Default fallback _ -> :magasin end _ -> # Default for single-part event IDs :magasin end end defp derive_otp_app_from_event_id(_), do: :magasin # Derive event directory from module name for file-based template loading # Example: Magasin.Accounts.Events.PasswordReset.Event -> lib/magasin/accounts/events/password_reset defp derive_event_dir_from_module(module, otp_app) when is_atom(module) do module_parts = module |> Module.split() |> Enum.map(&Macro.underscore/1) case module_parts do # Pattern: App.Domain.Events.EventName.Event [_app, domain | rest] when length(rest) >= 2 -> # Remove "Event" suffix from end: ["events", "new_reseller_request", "event"] -> ["events", "new_reseller_request"] path_parts = Enum.drop(rest, -1) # path_parts already includes "events", so don't add it again! relative_path = Path.join(["lib", to_string(otp_app), domain | path_parts]) # Convert to absolute path for file operations # In development/test: use source directory # In production: this won't be called (uses priv manifest instead) case Application.app_dir(otp_app) do dir when is_binary(dir) -> # App dir points to _build/ENV/lib/APP, we need to go to source # Check if we're in development/test (source available) source_path = Path.join([File.cwd!(), relative_path]) if File.exists?(source_path), do: source_path, else: nil _ -> nil end _ -> # Fallback: just use the module name path nil end end defp derive_event_dir_from_module(_, _), do: nil # Interpolate variables in a string template defp interpolate(nil, _context), do: nil defp interpolate(template, context) when is_binary(template) do # Use template_assigns which merges data and variables assigns = Context.template_assigns(context) AshDispatch.VariableInterpolator.interpolate(template, assigns, context.resource_key) end defp interpolate(value, _context), do: value # Helper to conditionally add a key-value pair to a map only if value is not nil # This prevents overwriting module callback values with nil in hybrid mode defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) defp build_module_content(context, channel, module, _event_config) do base_content = %{ transport: channel.transport, audience: channel.audience } # Add transport-specific content from callback module transport_content = case channel.transport do :email -> # Get variant for template resolution # Prefer channel.variant (from inline DSL) over callback variant = channel.variant || if function_exported?(module, :template_variant, 2) do module.template_variant(context, channel) else nil end # Prepare template assigns base_assigns = if function_exported?(module, :prepare_template_assigns, 2) do safe_prepare_template_assigns(module, context, channel) else %{} end # Merge context data and variables into assigns (variables take precedence) assigns = base_assigns |> Map.merge(Context.template_assigns(context)) # Get subject early and add to assigns so layout template can access it subject = module.subject(context, channel) assigns_with_subject = Map.put(assigns, :subject, subject) # Check if module has body_html/body_text callbacks (for test modules without templates) {html_body, text_body} = if function_exported?(module, :body_html, 2) && function_exported?(module, :body_text, 2) do # Use callbacks directly (for test modules or simple modules) {module.body_html(context, channel), module.body_text(context, channel)} else # Try to render templates using TemplateResolver (uses compiled templates or event_dir) # For hybrid mode with inline DSL, templates may not be found - that's OK, # inline DSL rendering will handle it # Derive OTP app from event_id for template resolution otp_app = derive_otp_app_from_event_id(context.event_id) # Derive event directory from module name for file-based loading in development event_dir = derive_event_dir_from_module(module, otp_app) html = case AshDispatch.TemplateResolver.render( event_module: module, event_dir: event_dir, otp_app: otp_app, format: :html, transport: :email, variant: variant, assigns: assigns_with_subject ) do {:ok, rendered} -> rendered {:error, :template_not_found} -> nil _ -> nil end text = case AshDispatch.TemplateResolver.render( event_module: module, event_dir: event_dir, otp_app: otp_app, format: :text, transport: :email, variant: variant, assigns: assigns_with_subject ) do {:ok, rendered} -> rendered {:error, :template_not_found} -> nil _ -> nil end {html, text} end # Get from as tuple and convert to map for JSON encoding {from_name, from_email} = module.from(context, channel) %{ subject: subject, from: %{"name" => from_name, "email" => from_email}, html_body: html_body, text_body: text_body } :in_app -> %{ title: module.notification_title(context, channel), message: module.notification_message(context, channel), action_url: module.action_url(context, channel), action_label: module.action_label(context, channel), notification_type: get_notification_type(module, context) } _ -> # For other transports, use basic message %{ message: module.notification_message(context, channel) } end Map.merge(base_content, transport_content) end defp get_notification_type(module, context) do if function_exported?(module, :notification_type, 1) do module.notification_type(context) else :info end end defp default_from_email do Application.get_env(:ash_dispatch, :default_from_email, "noreply@example.com") end defp dispatch_to_transport(receipt, context, channel, event_config) do case channel.transport do :in_app -> Transports.InApp.deliver(receipt, context, channel, event_config) :email -> Transports.Email.deliver(receipt, context, channel, event_config) :discord -> Transports.Discord.deliver(receipt, context, channel, event_config) :slack -> Transports.Slack.deliver(receipt, context, channel, event_config) :sms -> Transports.SMS.deliver(receipt, context, channel, event_config) :webhook -> Transports.Webhook.deliver(receipt, context, channel, event_config) unknown -> Logger.warning("Unknown transport: #{unknown}, skipping delivery") receipt |> Ash.Changeset.for_update(:skip, %{error_message: "Unknown transport: #{unknown}"}) |> Ash.update() end end # Helper to extract user from data map using Ash introspection # No hardcoded patterns - derives from Ash resource relationships defp extract_user_from_data(data) do user_module = Application.get_env(:ash_dispatch, :user_module) if is_nil(user_module) do Logger.warning("No :user_module configured in :ash_dispatch config") nil else # Strategy 1: Check if any value in data IS the user module # Strategy 2: Use Ash introspection to find user via relationships Enum.find_value(data, fn {_key, value} -> if is_struct(value) && value.__struct__ == user_module do value end end) || find_user_via_ash_relationships(data, user_module) end end # Find user by introspecting Ash resource relationships # Works for ANY resource that has a relationship to User module defp find_user_via_ash_relationships(data, user_module) do Enum.find_value(data, fn {_key, resource} -> # Only process Ash resources if is_struct(resource) && Ash.Resource.Info.resource?(resource.__struct__) do # Get all relationships defined on this resource relationships = Ash.Resource.Info.relationships(resource.__struct__) # Find any relationship pointing to the configured User module user_relationship = Enum.find(relationships, fn rel -> rel.destination == user_module end) # Extract user from that relationship if found if user_relationship do Map.get(resource, user_relationship.name) end end end) end # Counter broadcasting integration defp broadcast_counters(context, channel, event_config) do counter_broadcaster = Application.get_env(:ash_dispatch, :counter_broadcaster) if counter_broadcaster && function_exported?(counter_broadcaster, :broadcast, 3) do # Hybrid mode: prefer inline DSL counters over module callback counters = cond do # Check if channel has counters defined in inline DSL is_list(channel.counters) and channel.counters != [] -> channel.counters # Fall back to event module callback not is_nil(event_config[:module]) -> module = event_config[:module] if function_exported?(module, :counters, 2) do module.counters(context, channel) else [] end # No counters defined true -> [] end # Broadcast each counter via configured broadcaster Enum.each(counters, fn counter_name -> try do counter_broadcaster.broadcast(counter_name, context, channel) rescue error -> Logger.warning(""" Failed to broadcast counter Counter: #{counter_name} Event: #{context.event_id} Error: #{inspect(error)} """) end end) end :ok end # Apply channel-level load to the primary resource in context defp apply_channel_load(context, %{load: []} = _channel), do: context defp apply_channel_load(context, %{load: nil} = _channel), do: context defp apply_channel_load(context, %{load: load} = _channel) when is_list(load) do # Get the primary resource from context.data using resource_key resource_key = context.resource_key case Map.get(context.data, resource_key) do nil -> # No primary resource to load, return context as-is context record when is_struct(record) -> # Load additional relationships on the record # Use Ash.Resource.Info.domain/1 to safely get the domain case Ash.Resource.Info.domain(record.__struct__) do nil -> Logger.warning(""" Cannot load channel-level relationships #{inspect(load)} Resource #{inspect(record.__struct__)} has no domain configured Continuing with unloaded record... """) context domain -> case Ash.load(record, load, domain: domain, authorize?: false) do {:ok, loaded_record} -> # Update context with loaded record updated_data = Map.put(context.data, resource_key, loaded_record) %{context | data: updated_data} {:error, error} -> Logger.warning(""" Failed to load channel-level relationships #{inspect(load)} Error: #{inspect(error)} Continuing with unloaded record... """) context end end _other -> # Not a struct, can't load context end end defp apply_channel_load(context, _channel), do: context # Wrap prepare_template_assigns with helpful error messages for unloaded relationships defp safe_prepare_template_assigns(module, context, channel) do module.prepare_template_assigns(context, channel) rescue e in KeyError -> reraise_with_load_hint(e, context, __STACKTRACE__) e in UndefinedFunctionError -> # Happens when trying to call functions on NotLoaded struct if String.contains?(Exception.message(e), "Ash.NotLoaded") do reraise_with_load_hint(e, context, __STACKTRACE__) else reraise e, __STACKTRACE__ end e in Protocol.UndefinedError -> # Happens when trying to enumerate NotLoaded if String.contains?(Exception.message(e), "Ash.NotLoaded") do reraise_with_load_hint(e, context, __STACKTRACE__) else reraise e, __STACKTRACE__ end end defp reraise_with_load_hint(original_error, context, stacktrace) do event_id = context.event_id original_message = Exception.message(original_error) message = """ Failed to prepare template assigns for event "#{event_id}". #{original_message} This usually means a relationship wasn't loaded. Add it to the `load:` option in your event DSL: dispatch do event :your_event, trigger_on: :your_action, load: [:user, items: :product], # <-- Add missing relationships here ... end Or for nested relationships: load: [:user, product_order_items: :product] """ reraise RuntimeError.exception(message), stacktrace end end