Current section

Files

Jump to
ash_dispatch lib dispatcher.ex
Raw

lib/dispatcher.ex

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)
  - `:broadcast` → `AshDispatch.Transports.Broadcast` (lightweight, no receipt)
  - `:oban` → `AshDispatch.Transports.Oban` (lightweight, enqueues
    arbitrary worker named in event `:metadata`; no receipt)
  """

  alias AshDispatch.Config
  alias AshDispatch.Context
  alias AshDispatch.ChannelResolver
  alias AshDispatch.EventResolver
  alias AshDispatch.Naming
  alias AshDispatch.Event.RecipientExtractor
  alias AshDispatch.Helpers.RecordReader

  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 \\ %{})

  # Module-typed overload — resolve event_id via EventRegistry reverse-lookup.
  # Eliminates hand-typed event_id strings at call sites (typo risk). Forge.Binding
  # 019e2795-afd4 (Mosis project) — surfaced during predict_incremental wiring
  # where the dispatch ID `"admin_events.corpus_fill_completed"` was typed by hand
  # at the emission site with no compile-time validation against the DSL.
  #
  # Usage:
  #     AshDispatch.Dispatcher.dispatch(MyApp.Events.OrderCreated, %{...}, %{...})
  # Equivalent to:
  #     AshDispatch.Dispatcher.dispatch(MyApp.Events.OrderCreated.id() or registry-id, ...)
  def dispatch(module, data, variables)
      when is_atom(module) and is_map(data) and is_map(variables) do
    case resolve_event_id_for_module(module) do
      {:ok, event_id} ->
        dispatch(event_id, data, variables)

      :error ->
        {:error,
         {:event_module_not_registered,
          "module #{inspect(module)} is not registered in any Ash domain's dispatch DSL — " <>
            "either register it via `event(:x, module: #{inspect(module)})` or use the string-id form."}}
    end
  end

  def dispatch(event_id, data, variables)
      when is_binary(event_id) and is_map(data) and is_map(variables) do
    # Use centralized EventResolver for consistent event lookup
    case EventResolver.find_module(event_id) do
      {:ok, 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 using EventResolver (handles function_exported? and error handling)
        # This allows events to provide real data (tokens, etc.) for actual sending
        # while using sample_data() for previews
        enhanced_vars_result =
          EventResolver.generate_send_variables(event_module, initial_context, variables)

        case enhanced_vars_result do
          {:ok, enhanced_variables} ->
            # Update context with enhanced variables
            context = %{initial_context | variables: enhanced_variables}

            # Use centralized ChannelResolver for consistent priority logic
            channels = ChannelResolver.resolve(event_id, event_module, context)

            # Build event config — pull the inline event's :metadata +
            # :content into event_config so transports that depend on
            # them (e.g. AshDispatch.Transports.Oban needs
            # event_config[:metadata][:oban_worker]) read the substrate
            # the DSL declared. Pre-fix, event_config only carried
            # :module, so :oban transport always logged
            # "missing oban_worker in metadata" and silently skipped
            # the enqueue. ω.13.swarm-2c103-test-bugs.
            event_config =
              case AshDispatch.EventRegistry.find_event(event_id) do
                {:ok, event_info} ->
                  %{
                    module: event_module,
                    metadata: event_info[:metadata] || [],
                    content: event_info[:content] || []
                  }

                {:error, _} ->
                  %{module: event_module}
              end

            # Dispatch to all channels
            # Deduplication is opt-in via deduplicate_in_app: true in event config
            results = dispatch_all_channels(channels, context, event_config)

            # 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

      {:error, :not_found} ->
        Logger.error("Event module not found for event_id: #{event_id}")
        {:error, :event_not_found}
    end
  end

  @doc """
  `dispatch/3` wrapped in rescue + log, always returning `:ok`. Never raises.

  For call sites where dispatch failure must NOT fail the upstream operation:
  background fan-out signals, observability events, admin broadcasts. Where
  delivery failure should fail the caller (transactional side-effects,
  user-facing notifications the action depends on), call `dispatch/3`
  directly and handle the return value.
  """
  @spec dispatch_safely(String.t() | module(), map(), map()) :: :ok
  def dispatch_safely(event, data \\ %{}, variables \\ %{}) do
    case dispatch(event, data, variables) do
      {:ok, _} ->
        :ok

      {:error, reason} ->
        Logger.warning("dispatch_safely: #{inspect(event)} failed: #{inspect(reason)}")
        :ok
    end
  rescue
    error ->
      Logger.warning("dispatch_safely: #{inspect(event)} raised: #{Exception.message(error)}")
      :ok
  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
    if channel_should_send?(context, channel, event_config) do
      do_dispatch_channel(context, channel, event_config)
    else
      Logger.debug(
        "Dispatch skipped by should_send?/2: #{context.event_id} (#{channel.transport}/#{channel.audience})"
      )

      {:ok, :skipped_should_send}
    end
  end

  defp do_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)

    # Filter out actor if exclude_actor is set
    recipients = filter_out_actor(recipients, context, channel)

    # 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

  # Dispatch to all channels with optional deduplication based on deduplicate_group
  # Channels with the same deduplicate_group are deduplicated - user gets max one notification per group
  defp dispatch_all_channels(channels, context, event_config) do
    # Check if any channels have deduplicate_group set
    has_dedup_groups? = Enum.any?(channels, & &1.deduplicate_group)

    if has_dedup_groups? do
      dispatch_with_deduplication(channels, context, event_config)
    else
      # No deduplication - dispatch each channel independently (default)
      Enum.map(channels, fn channel ->
        dispatch_channel(context, channel, event_config)
      end)
    end
  end

  # `should_send?/2` is the event's last-moment guard (e.g. "skip the welcome
  # mail if the user confirmed in the meantime"). It was declared on the
  # behaviour — and implemented by consumer events — but never invoked by the
  # send path, so those guards silently never ran. A guard must never abort
  # dispatch by raising: default to send.
  defp channel_should_send?(context, channel, event_config) do
    case event_config[:module] do
      nil ->
        true

      module ->
        if function_exported?(module, :should_send?, 2) do
          module.should_send?(context, channel) != false
        else
          true
        end
    end
  rescue
    error ->
      Logger.warning(
        "should_send?/2 raised for #{context.event_id}; sending anyway: #{inspect(error)}"
      )

      true
  end

  # Dispatch channels with deduplication based on deduplicate_group
  defp dispatch_with_deduplication(channels, context, event_config) do
    # Skip channels the event's should_send?/2 guard rejects (same gate as
    # dispatch_channel/3 — this path collects recipients itself).
    channels = Enum.filter(channels, &channel_should_send?(context, &1, event_config))

    # Collect all recipients from all channels with their channel info
    recipients_with_channels =
      Enum.flat_map(channels, fn channel ->
        channel_context = apply_channel_load(context, channel)
        recipients = resolve_recipients_for_channel(channel_context, channel, event_config)

        # Filter out actor if exclude_actor is set for this channel
        recipients = filter_out_actor(recipients, channel_context, channel)

        Enum.map(recipients, fn recipient ->
          {recipient, channel}
        end)
      end)

    # Apply deduplication - users in the same deduplicate_group get max one notification
    deduplicated_recipients = apply_group_deduplication(recipients_with_channels)

    # Dispatch to each recipient using their associated channel
    Enum.map(deduplicated_recipients, fn {recipient, channel} ->
      channel_context = apply_channel_load(context, channel)
      dispatch_to_recipient(channel_context, channel, event_config, recipient)
    end)
  end

  # Apply deduplication based on channel's deduplicate_group
  # Channels with same group are deduplicated - first channel (by DSL order) wins
  # Channels without a group are never deduplicated
  defp apply_group_deduplication(recipients_with_channels) do
    # Track which user IDs we've seen per dedup group
    {result, _seen} =
      Enum.reduce(recipients_with_channels, {[], %{}}, fn {recipient, channel} = item,
                                                          {acc, seen} ->
        user_id = get_user_id(recipient)
        dedup_group = channel.deduplicate_group

        cond do
          # No dedup group - always include
          is_nil(dedup_group) ->
            {[item | acc], seen}

          # Check if we've already seen this user in this dedup group
          MapSet.member?(Map.get(seen, dedup_group, MapSet.new()), user_id) ->
            # Skip - user already received notification for this group
            {acc, seen}

          true ->
            # First time seeing this user in this group - include and mark as seen
            new_seen =
              Map.update(seen, dedup_group, MapSet.new([user_id]), &MapSet.put(&1, user_id))

            {[item | acc], new_seen}
        end
      end)

    Enum.reverse(result)
  end

  # Private function that dispatches to a specific recipient
  defp dispatch_to_recipient(context, channel, event_config, recipient) do
    # Skip receipt creation for lightweight transports (e.g., :broadcast)
    if skip_receipt_for_transport?(channel.transport) do
      dispatch_without_receipt(context, channel, event_config, recipient)
    else
      dispatch_with_receipt(context, channel, event_config, recipient)
    end
  end

  # F1 — receipt-skip is now declared on each Transport via the
  # `use AshDispatch.Transport, skip_receipt?: bool` macro. Lookup
  # routes through `AshDispatch.Transport.Registry` so adding a
  # lightweight transport (broadcast / oban / future) is a single
  # `use` line — no Dispatcher edit needed.
  defp skip_receipt_for_transport?(transport_atom),
    do: AshDispatch.Transport.Registry.skip_receipt?(transport_atom)

  defp extract_user_id_from_recipient(%{id: id}), do: id
  defp extract_user_id_from_recipient(%{"id" => id}), do: id
  defp extract_user_id_from_recipient(_), do: nil

  defp dispatch_without_receipt(context, channel, event_config, recipient) do
    # Build a minimal pseudo-receipt for the transport (no DB write)
    pseudo_receipt = %{
      id: nil,
      event_id: context.event_id,
      transport: channel.transport,
      audience: channel.audience,
      user_id: extract_user_id_from_recipient(recipient),
      content: %{},
      status: :pending,
      metadata: %{}
    }

    case dispatch_to_transport(pseudo_receipt, context, channel, event_config) do
      {:ok, _} ->
        Logger.debug("Broadcast dispatched: #{context.event_id} to #{channel.audience}")
        {:ok, :broadcast_sent}

      {:error, reason} ->
        Logger.warning("Broadcast failed: #{context.event_id} — #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp dispatch_with_receipt(context, channel, event_config, recipient) do
    case create_receipt(context, channel, event_config, recipient) do
      {:ok, :skipped_optional} ->
        # Optional channel that can't be delivered (e.g. SMS recipient
        # without a phone number). Counted as success — the channel
        # opted into "best-effort" delivery via `optional: true`.
        {:ok, :skipped_optional}

      {: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
    # Resolve module with runtime fallback (handles compilation order issues)
    module = resolve_event_module(event_config, context)

    # Priority order for recipient resolution:
    # 1. recipient_resolver config (new DSL-based system)
    # 2. Event module recipients/2 callback (if returns non-empty)
    # 3. Legacy audience config (MFA-based)
    cond do
      # New: Use configured recipient resolver module
      resolver = Config.recipient_resolver() ->
        resource = extract_primary_resource(context)
        resolver.resolve(channel.audience, resource, context)

      # 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, try its recipients callback first
      # But fall back to audience-based resolution if it returns empty
      not is_nil(module) ->
        case EventResolver.recipients(module, context, channel) do
          # Module returned recipients - use them
          recipients when is_list(recipients) and recipients != [] ->
            recipients

          # Module returned empty list - fall back to audience-based resolution
          # This handles both explicit empty returns and default [] from call_if_exported
          _ ->
            AshDispatch.Event.Helpers.resolve_recipients_for_audience(
              context,
              channel,
              event_config
            )
        end

      # Pure inline DSL without module - use helpers with app-level config
      true ->
        AshDispatch.Event.Helpers.resolve_recipients_for_audience(context, channel, event_config)
    end
  end

  # Extract the primary resource from context for recipient resolution
  defp extract_primary_resource(context) do
    # Priority order:
    # 1. Use resource_key if specified
    # 2. Look for common resource keys in data
    # 3. Return first struct found in data
    cond do
      context.resource_key && Map.has_key?(context.data, context.resource_key) ->
        Map.get(context.data, context.resource_key)

      true ->
        # Try common resource keys, then fall back to first struct
        common_keys = [:project, :lead, :order, :meeting, :business_plan, :offer, :phase]

        Enum.find_value(common_keys, fn key ->
          case Map.get(context.data, key) do
            value when is_struct(value) -> value
            _ -> nil
          end
        end) ||
          context.data
          |> Map.values()
          |> Enum.find(&is_struct/1)
    end
  end

  defp create_receipt(context, channel, event_config, recipient) do
    # Build full content for the receipt. Recipient is threaded in so
    # per-recipient locale resolution (recipient.locale) can flip the
    # rendered subject/body/text per recipient — multi-recipient channels
    # no longer share a single pre-rendered content blob when locales
    # differ.
    content = build_receipt_content(context, channel, event_config, recipient)

    # Extract recipient identifier and name using configured fields.
    # `:skip` is the soft-fail return when `channel.optional: true` and
    # the identifier can't be extracted — propagate up so the caller
    # skips this channel without crashing the whole dispatch.
    case extract_recipient_identifier(recipient, channel, event_config) do
      :skip ->
        {:ok, :skipped_optional}

      # Den här mottagaren gick inte att nå. Ett FEL på raden, inte på
      # kanalen — `do_dispatch_channel/3` räknar redan "minst en lyckades",
      # så resten av mottagarna går igenom.
      :unreachable ->
        {:error, :recipient_unreachable}

      recipient_identifier ->
        do_create_receipt(
          context,
          channel,
          event_config,
          recipient,
          content,
          recipient_identifier
        )
    end
  end

  defp do_create_receipt(context, channel, event_config, recipient, content, recipient_identifier) do
    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)

    # Resolve locale for traceability (which template was rendered).
    # Per-recipient locale (recipient.locale) is considered so the receipt
    # records the actual locale that was used to render the recipient's
    # content blob.
    locale = resolve_channel_locale(channel, context, recipient)

    # 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,
      locale: locale
      # 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
    Config.delivery_receipt_resource()
    |> Ash.Changeset.for_create(:create, attrs)
    |> Ash.create(authorize?: false, skip_unknown_inputs: [:notification_id])
  end

  # Extract recipient identifier using RecipientExtractor with cascading config.
  # When the channel is marked `optional: true` and the identifier can't be
  # extracted (e.g. SMS channel where the user has no phone_number), we
  # log + return `:skip` instead of crashing the whole dispatch — the
  # caller skips the receipt creation for this channel only.
  defp extract_recipient_identifier(recipient, channel, event_config) do
    RecipientExtractor.extract_identifier(
      recipient,
      channel.transport,
      channel.audience,
      event_config[:recipient]
    )
  rescue
    error ->
      if Map.get(channel, :optional, false) do
        Logger.info(
          "Skipping optional channel — recipient has no #{channel.transport} identifier " <>
            "(transport=#{channel.transport} audience=#{channel.audience})"
        )

        :skip
      else
        Logger.error("""
        Failed to extract recipient identifier — SKIPPING THIS RECIPIENT
        Transport: #{channel.transport}
        Audience: #{channel.audience}
        Recipient: #{inspect(recipient)}
        Error: #{inspect(error)}
        """)

        # `:unreachable` and NOT `reraise`. The raise escaped `Enum.map/2` in
        # `do_dispatch_channel/3` and took the WHOLE CHANNEL down — every other
        # recipient included — even though that function already tolerates
        # partial failure and returns success when at least one dispatch
        # worked. One unreachable recipient must cost exactly one recipient.
        #
        # Seen in magasin 2026-09-02: an audience resolved to the buyer plus
        # their company, the company was a login-less grouping row with no
        # email, and the customer's order confirmation was never created. The
        # buyer had a perfectly good address; they lost their letter to
        # somebody else's missing one.
        #
        # The error is still logged, and loudly — the change is who pays for
        # it.
        #
        # A distinct atom from the `optional: true` case above: that one is a
        # deliberate skip and counts as success, this one is a failure that
        # happens to be survivable. Collapsing them would report a channel as
        # delivered when nobody could be reached.
        :unreachable
      end
  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

  # Adds the recipient to the context's variables so both
  # `prepare_template_assigns/2` and the template can see who this copy is
  # for. Never overwrites an existing `:recipient` — an event that resolves a
  # richer shape of its own keeps it.
  defp put_recipient(%{variables: variables} = context, recipient)
       when is_map(variables) do
    %{context | variables: Map.put_new(variables, :recipient, recipient)}
  end

  defp put_recipient(context, _recipient), do: context

  # 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

  # Filter out the actor (user who triggered the event) from recipients
  # Only applies when channel.exclude_actor is true and context.user is set
  defp filter_out_actor(recipients, %{user: %{id: actor_id}}, %{exclude_actor: true})
       when not is_nil(actor_id) do
    Enum.reject(recipients, fn recipient ->
      get_user_id(recipient) == actor_id
    end)
  end

  defp filter_out_actor(recipients, _context, _channel), do: recipients

  # 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
    # Resolve module with runtime fallback (handles compilation order issues)
    module = resolve_event_module(event_config, context)

    cond do
      # Module-based events: use resource() callback to get the source type
      not is_nil(module) and not is_nil(EventResolver.resource(module)) ->
        resource_module = EventResolver.resource(module)
        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
    # Use EventResolver for safe callback execution
    EventResolver.data_key(module) || context.resource_key
  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 Config.delivery_receipt_resource()
         |> 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, recipient) do
    # Set per-recipient Gettext locale BEFORE rendering callbacks so any
    # `dgettext` invocations inside `prepare_template_assigns`,
    # `notification_title/message`, content-string interpolation, and
    # `EEx.eval_string` template rendering pick up the right locale.
    # This is the same backend referenced via the `:gettext_backend`
    # config — set to a no-op when not configured.
    #
    # `Gettext.put_locale/2` is process-local, so we wrap the body in
    # `try/after` to restore whatever locale was in effect when this
    # receipt started building. Without that, a worker that dispatches
    # to user A (locale="en") and then runs unrelated `t()` calls in
    # the same process would observe the leaked "en" locale until the
    # next explicit `put_locale`. See PR with regression test.
    prev_locale = current_locale()
    apply_recipient_locale(channel, context, recipient)

    try do
      do_build_receipt_content(context, channel, event_config, recipient)
    after
      restore_locale(prev_locale)
    end
  end

  defp do_build_receipt_content(context, channel, event_config, recipient) do
    # THE RECIPIENT BELONGS IN THE CONTEXT.
    #
    # This function runs once PER RECIPIENT and has always had it in scope,
    # but nothing downstream could see it: `prepare_template_assigns/2`
    # receives `(context, channel)`, and the template assigns were built from
    # that return value plus `Context.template_assigns/1`. A greeting by name
    # was therefore impossible without reaching outside the render path.
    #
    # It goes into `context.variables`, which is the one place that reaches
    # BOTH consumers — `Context.template_assigns/1` merges variables into the
    # assigns a template sees, and the callback can read
    # `context.variables[:recipient]` to PRECOMPUTE anything per-recipient.
    #
    # That second half is the point. Putting it only in the final assigns
    # would have forced consumers to branch inside templates, which is exactly
    # what a precomputed-assigns convention exists to prevent.
    #
    # Purely additive: a context key nothing reads changes no output, so an
    # event that never mentions the recipient renders byte-for-byte as before.
    context = put_recipient(context, recipient)

    # Resolve module with runtime fallback (handles compilation order issues)
    module = resolve_event_module(event_config, context)

    base_content =
      case module do
        nil ->
          # Pure inline DSL - use inline content only
          build_inline_content(context, channel, event_config, recipient)

        module ->
          # Hybrid mode: module + inline DSL
          # Step 1: Enhance context with module's prepare_template_assigns if available
          extra_assigns = safe_prepare_template_assigns(module, context, channel)

          enhanced_context =
            if map_size(extra_assigns) > 0 do
              # 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, recipient)

          # 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, recipient)

            # 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

  # Read the current process-level Gettext locale so `build_receipt_content/4`
  # can restore it after rendering. Returns nil when no backend is configured
  # or when `Gettext` isn't loaded.
  defp current_locale do
    case Config.gettext_backend() do
      nil ->
        nil

      backend ->
        if Code.ensure_loaded?(Gettext) do
          try do
            apply(Gettext, :get_locale, [backend])
          rescue
            _ -> nil
          end
        else
          nil
        end
    end
  end

  # Restore a previously-captured Gettext locale. Used in the `after`
  # block of `build_receipt_content/4` so per-recipient locale changes
  # don't leak to subsequent code paths in the same process.
  defp restore_locale(nil), do: :ok

  defp restore_locale(locale) when is_binary(locale) do
    case Config.gettext_backend() do
      nil ->
        :ok

      backend ->
        if Code.ensure_loaded?(Gettext) do
          try do
            apply(Gettext, :put_locale, [backend, locale])
            :ok
          rescue
            _ -> :ok
          end
        else
          :ok
        end
    end
  end

  # Set Gettext locale to the resolved per-channel-per-recipient locale.
  # No-op when no backend is configured or when the resolved locale is nil.
  defp apply_recipient_locale(channel, context, recipient) do
    case Config.gettext_backend() do
      nil ->
        :ok

      backend ->
        case resolve_channel_locale(channel, context, recipient) do
          locale when is_binary(locale) and locale != "" ->
            try do
              apply(Gettext, :put_locale, [backend, locale])
              :ok
            rescue
              _ -> :ok
            end

          _ ->
            :ok
        end
    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, recipient) 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).
          # Recipient threaded through so locale resolution can pick
          # recipient.locale when present.
          {html_body, text_body} =
            render_inline_email_templates(context, channel, event_config, recipient)

          # Build content - only include fields with actual values (to not overwrite module callbacks in hybrid mode)
          %{}
          |> maybe_put(:from, content_config[:from_email])
          |> 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, interpolate(content_config[:action_label], context))

        :discord ->
          # `maybe_put` och inte en literal nyckel: `interpolate(nil, _)` ger
          # `nil`, och en `%{message: nil}` hade skrivit över modulens
          # callback-text med ingenting i hybridläget. Se `:webhook` nedan.
          %{}
          |> maybe_put(
            :message,
            interpolate(
              content_config[:message] || content_config[:notification_message],
              context
            )
          )
          |> Map.put(:webhook_url, channel.webhook_url)

        :slack ->
          # `maybe_put` och inte en literal nyckel: `interpolate(nil, _)` ger
          # `nil`, och en `%{message: nil}` hade skrivit över modulens
          # callback-text med ingenting i hybridläget. Se `:webhook` nedan.
          %{}
          |> maybe_put(
            :message,
            interpolate(
              content_config[:message] || content_config[:notification_message],
              context
            )
          )
          |> Map.put(:webhook_url, channel.webhook_url)

        :sms ->
          # Se `:discord` ovan om varför `maybe_put`.
          %{}
          |> maybe_put(
            :message,
            interpolate(
              content_config[:message] || content_config[:notification_message],
              context
            )
          )

        :webhook ->
          # Texten läses HÄR också, precis som i varje annan transport.
          #
          # Den här grenen var den enda som inte gjorde det, och följden var
          # tyst: ett event med en modul faller tillbaka på modulens
          # `notification_message/2`, vars genererade default är
          # "You have a new notification". Merge-ordningen i `build_content/5`
          # låter modulens värde stå kvar för varje nyckel inline INTE sätter
          # — så en deklarerad `content: [message: ...]` på en
          # `transport: :webhook`-kanal blev dekoration, och mottagaren fick
          # platshållaren med rätt form och fel innehåll.
          #
          # Mätt hos en konsument innan fixen: 41 av 41 levererade
          # webhook-kvitton bar platshållaren, fördelade på nio deklarerade
          # kanaler. Ingen av texterna hade någonsin nått fram.
          #
          # `maybe_put` och inte `Map.put`: saknas texten i DSL:en ska
          # modulens callback fortsätta vinna (hybridläget). Jämför `:discord`
          # och `:slack` ovan, som skriver `message:` ovillkorligt och därmed
          # kan skriva över ett modulvärde med `nil`.
          #
          # `action_url`/`action_label` läses av samma skäl, och samma
          # mätning visade behovet: en mottagare rapporterade kanalposter som
          # "döda notiser" — laget fick veta att något hänt men hade ingen väg
          # dit. `:in_app` ovan har alltid burit dem; `:webhook` var den enda
          # transporten där en deklarerad `action_url:` tyst föll bort, så en
          # avsändare som skrev en hade ingen möjlighet att upptäcka det utom
          # genom att läsa det som kom fram.
          #
          # `:discord`, `:slack` och `:sms` får dem MEDVETET inte: deras
          # nyttolaster har ingen egen knappform, och en url utan en yta som
          # renderar den är en nyckel som bara ser ut att göra något.
          %{}
          |> 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, interpolate(content_config[:action_label], context))
          |> Map.put(:payload, content_config[:webhook_payload] || %{})
          |> Map.put(:webhook_url, channel.webhook_url)

        :push ->
          # Titel, text och destination. Håll det litet: push-tjänsterna
          # kapar krypterade nyttolaster runt 4 KB, och allt annat kan
          # hämtas när användaren väl tryckt.
          %{}
          |> 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))

        # Catch-all. Utan den kraschar HELA dispatchen med CaseClauseError
        # så snart en transport registreras utan en gren här — vilket är
        # precis vad `AshDispatch.Transport`-beteendet lovar att man ska
        # slippa ("en ny fil + en rad i registret"). En transport utan
        # inline-content får ett tomt innehåll och levererar ändå; dess
        # backend läser typiskt receipt.content eller kontexten själv.
        _ ->
          %{}
      end

    Map.merge(base_content, transport_content)
  end

  # Render email templates for inline DSL events
  defp render_inline_email_templates(context, channel, event_config, recipient) 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
    # Locale priority: channel.locale > channel.locale_from (dynamic) >
    # recipient.locale > context.locale
    locale = resolve_channel_locale(channel, context, recipient)

    # 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,
             locale: locale,
             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,
             locale: locale,
             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 - use configured value (single source of truth)
  defp derive_otp_app_from_event_id(_event_id) do
    Config.otp_app() || :magasin
  end

  # Derive event directory from module name for file-based template loading
  # Uses Naming.module_directory for consistent path derivation
  # 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
    # Use Naming for consistent path derivation
    relative_path = Naming.module_directory(module)

    # Convert to absolute path for file operations
    # In development/test: use source directory
    # In production: this won't be called (uses priv manifest instead)
    source_path = Path.join([File.cwd!(), relative_path])
    if File.exists?(source_path), do: source_path, else: nil
  rescue
    _ -> nil
  end

  # Interpolate variables in a string template
  defp interpolate(nil, _context), do: nil

  defp interpolate(template, context) when is_binary(template) do
    # Step 1: Translate via Gettext if backend configured
    translated = translate_content(template, context.locale)

    # Step 2: Variable interpolation ({{variable}} → value)
    assigns = Context.template_assigns(context)
    AshDispatch.VariableInterpolator.interpolate(translated, assigns, context.resource_key)
  end

  defp interpolate(value, _context), do: value

  # Translate content string via Gettext if a backend is configured.
  # The content string is the msgid, looked up in the configured domain
  # (`AshDispatch.Config.gettext_domain/0`, default `"notifications"`).
  # Gettext calls are dynamic to avoid requiring gettext as a dependency.
  #
  # **Locale handling:** if `locale` is non-nil we set it explicitly;
  # otherwise we trust the process-level locale that
  # `apply_recipient_locale/3` already put in place (per-recipient
  # resolution from 0.4.5). Previously this unconditionally reset the
  # locale to `"en"` for nil input, which silently undid recipient-locale
  # resolution for DSL-content lookups.
  defp translate_content(string, locale) do
    case AshDispatch.Config.gettext_backend() do
      nil ->
        string

      backend ->
        if Code.ensure_loaded?(Gettext) do
          if is_binary(locale) and locale != "" do
            apply(Gettext, :put_locale, [backend, locale])
          end

          apply(Gettext, :dgettext, [backend, AshDispatch.Config.gettext_domain(), string])
        else
          string
        end
    end
  end

  # 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, recipient) 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 EventResolver callback
          variant = channel.variant || EventResolver.template_variant(module, context, channel)
          # Locale priority: channel.locale > channel.locale_from (dynamic) >
          # recipient.locale > context.locale
          locale = resolve_channel_locale(channel, context, recipient)

          # Prepare template assigns using EventResolver
          base_assigns = safe_prepare_template_assigns(module, context, channel)

          # 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 EventResolver.has_body_callbacks?(module) do
              # Use callbacks directly (for test modules or simple modules)
              {EventResolver.body_html(module, context, channel),
               EventResolver.body_text(module, 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,
                       locale: locale,
                       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,
                       locale: locale,
                       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},
            # `nil` när eventet inte har någon svarsväg — nyckeln bärs ändå,
            # så ett kvitto kan läsas i efterhand utan att skilja "ingen
            # svarsadress" från "biblioteket var äldre än fältet".
            reply_to: EventResolver.reply_to(module, context, channel),
            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)
          }

        :webhook ->
          # Samma nycklar som `:in_app`, och av samma skäl: mottagaren
          # renderar ett KORT. En modulbaserad webhook-händelse kunde till
          # 0.8.0 bara bidra med `message` — alltså kunde den inte sätta en
          # rubrik, och inte en väg vidare, hur gärna den än ville.
          #
          # Mätt hos en konsument: av sex distinkta kanalposter i produktion
          # bar EN en rubrik, och noll bar en länk. Det lästes som "ingen har
          # brytt sig", men vägen fanns inte.
          %{
            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)
          }

        _ ->
          # For other transports, use basic message
          %{
            message: module.notification_message(context, channel)
          }
      end

    # `extra_content/2` — modulens egna nycklar, för det mottagaren kan
    # rendera och biblioteket inte känner till.
    #
    # Den ligger UNDER transportens egna nycklar med flit: en modul som
    # returnerar `%{message: ...}` här ska inte kunna tysta
    # `notification_message/2`, och den som vill byta text har redan en
    # callback för det. Tillägget är för det som saknas, aldrig för att
    # skriva om det som finns.
    extra = EventResolver.extra_content(module, context, channel)

    base_content
    |> Map.merge(extra)
    |> Map.merge(transport_content)
  end

  defp get_notification_type(module, context) do
    # Use EventResolver for safe callback execution with default :info
    EventResolver.notification_type(module, context)
  end

  # F1 — transport dispatch routes through `AshDispatch.Transport.Registry`
  # (compile-time `atom => module` map). Adding a transport is one new
  # file + one entry in the registry's @transports list — no edit here.
  defp dispatch_to_transport(receipt, context, channel, event_config) do
    case AshDispatch.Transport.Registry.module_for(channel.transport) do
      {:ok, module} ->
        module.deliver(receipt, context, channel, event_config)

      :error ->
        Logger.warning("Unknown transport: #{channel.transport}, skipping delivery")

        receipt
        |> Ash.Changeset.for_update(:skip, %{
          error_message: "Unknown transport: #{channel.transport}"
        })
        |> Ash.update(authorize?: false)
    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 = Config.user_module()

    if is_nil(user_module) do
      # An app without a user resource is a valid configuration (custom
      # recipient resolvers handle non-user recipients) — this runs on every
      # dispatch, so warning-level would be permanent log spam there.
      Logger.debug("No :user_module configured in :ash_dispatch config")
      nil
    else
      # Strategy 1: Check if any value in data IS the user module struct
      # Strategy 2: Use Ash introspection to find user via relationships
      # Strategy 3: Accept bare map with :id under :user or :actor key
      # (manual pipeline events pass %{user: %{id: user_id}} without loading the full struct)
      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) ||
        extract_bare_user_map(data)
    end
  end

  defp extract_bare_user_map(data) do
    case data do
      %{user: %{id: _} = bare} when not is_struct(bare) -> bare
      %{actor: %{id: _} = bare} when not is_struct(bare) -> bare
      _ -> nil
    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 = Config.counter_broadcaster()

    if counter_broadcaster && Code.ensure_loaded?(counter_broadcaster) &&
         function_exported?(counter_broadcaster, :broadcast, 3) do
      # Resolve module with runtime fallback (handles compilation order issues)
      module = resolve_event_module(event_config, context)

      # 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 using EventResolver
          not is_nil(module) ->
            EventResolver.counters(module, context, channel)

          # 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

  # Always resolve event 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)
  # Warning is only logged in dispatch_event.ex on first resolution failure
  defp resolve_event_module(_event_config, context) do
    case EventResolver.find_module(context.event_id) do
      {:ok, m} -> m
      {:error, :not_found} -> nil
    end
  end

  # Resolve locale for a channel with priority:
  # 1. channel.locale - static locale configured on channel
  # 2. channel.locale_from - dynamic locale from record field
  # 3. recipient.locale - per-recipient preference (NEW in 0.4.5; only
  #    consulted when the recipient struct carries a non-nil `:locale`
  #    field — typically a User record for `audience: :user`)
  # 4. context.locale - event-level locale (already resolved from record
  #    or default)
  @doc false
  def resolve_channel_locale(channel, context, recipient) do
    cond do
      # Static locale on channel has highest priority
      channel.locale ->
        channel.locale

      # Dynamic locale from record field. When the field is set but
      # the record's value is nil/missing, cascade through the rest of
      # the chain (recipient.locale → context.locale) instead of
      # bailing on `context.locale`. Makes `locale_from` describe a
      # *preference*, not a hard constraint.
      channel.locale_from ->
        record_locale(context, channel.locale_from) ||
          recipient_locale(recipient) || context.locale

      # Per-recipient locale (e.g. User.locale) — wins over the
      # event/resource-level fallback so each recipient sees their own
      # language.
      locale = recipient_locale(recipient) ->
        locale

      # Fall back to context locale (event-level)
      true ->
        context.locale
    end
  end

  # Extract `:locale` from a recipient struct/map. Returns nil if the
  # field is missing, nil, or not a non-empty binary — we explicitly
  # reject empty strings so a blank `User.locale` doesn't shadow
  # downstream fallbacks.
  defp recipient_locale(nil), do: nil

  defp recipient_locale(recipient) when is_map(recipient) do
    case Map.get(recipient, :locale) do
      locale when is_binary(locale) and locale != "" -> locale
      _ -> nil
    end
  end

  defp recipient_locale(_), do: nil

  # Read `locale_field` off `context.data[resource_key]`. Returns nil
  # (not `context.locale`) when missing so the caller controls the
  # cascade. Treats `%Ash.NotLoaded{}` as nil via RecordReader.
  defp record_locale(context, locale_field) do
    resource_key = context.resource_key

    case Map.get(context.data, resource_key) do
      record when is_map(record) -> RecordReader.safe_get(record, locale_field)
      _ -> nil
    end
  end

  # Wrap prepare_template_assigns with helpful error messages for unloaded relationships
  # Uses EventResolver for safe callback execution, but adds extra error handling for NotLoaded
  defp safe_prepare_template_assigns(module, context, channel) do
    # First check if the module exports the function
    if EventResolver.exports?(module, :prepare_template_assigns, 2) do
      try 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
    else
      %{}
    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

  # Reverse-lookup: module → event_id via EventRegistry.
  # Used by the module-typed dispatch/3 overload to resolve dispatch IDs
  # from event modules instead of hand-typed strings.
  @spec resolve_event_id_for_module(module()) :: {:ok, String.t()} | :error
  defp resolve_event_id_for_module(module) do
    case Enum.find(AshDispatch.EventRegistry.get_event_modules(), fn {_id, mod} ->
           mod == module
         end) do
      {event_id, _module} when is_binary(event_id) -> {:ok, event_id}
      _ -> :error
    end
  end
end