DSL: AshDispatch.Resource

View Source

The AshDispatch.Resource extension adds event dispatching capabilities to Ash resources.

Table of Contents

  • dispatch - Top-level section for defining events
    • event - Define an event that dispatches when actions occur
    • locales - Configure locales for multi-language templates
  • Actor Access - Accessing the actor (user who triggered the action) in events
  • Localization - Multi-language template support

dispatch

The dispatch section contains event definitions for the resource.

Usage

defmodule MyApp.Orders.ProductOrder do
  use Ash.Resource,
    extensions: [AshDispatch.Resource]

  dispatch do
    event :created, trigger_on: :create_from_cart do
      # event configuration...
    end

    event :cancelled, trigger_on: :cancel do
      # event configuration...
    end
  end
end

Entities


dispatch.locales

Configures locales for multi-language template generation and runtime locale resolution.

Arguments

NameTypeRequiredDescription
locales[String.t()]List of locale codes (e.g., ["sv", "en", "no"])

Options

NameTypeDefaultRequiredDescription
default_localeString.t()config defaultFallback locale when none can be determined from record
locale_fromatomnilField on resource to extract runtime locale from

Examples

Resource-level locale configuration

dispatch do
  locales ["sv", "en", "no"],
    default_locale: "sv",
    locale_from: :visitor_locale

  event :created, trigger_on: :create do
    channels [
      [transport: :email, audience: :customer],
      [transport: :email, audience: :admin, locale: "sv"]
    ]
  end
end

This generates:

  • email.html.heex (default/fallback)
  • email.sv.html.heex
  • email.en.html.heex
  • email.no.html.heex

At runtime:

  • Customer email reads lead.visitor_locale to select template
  • Admin email always uses Swedish template (channel-level override)

Locale Resolution Priority

When determining which locale to use at runtime:

  1. Channel-level locale - Static override (e.g., locale: "sv")
  2. Channel-level locale_from - Dynamic from record field
  3. Event-level locale_from - Event-specific field
  4. Resource-level locale_from - Resource-wide field (this config)
  5. Auto-detected fields - :visitor_locale, :locale on record
  6. Config default - AshDispatch.Config.default_locale()

Template Fallback Chain

When resolving templates, AshDispatch tries these in order:

  1. email.admin.sv.html.heex (variant + locale)
  2. email.admin.html.heex (variant only)
  3. email.sv.html.heex (locale only)
  4. email.html.heex (base template)
  5. default.sv.html.heex (default + locale)
  6. default.html.heex (ultimate fallback)

dispatch.event

Defines an event that is automatically dispatched when specified actions occur.

Arguments

NameTypeRequiredDescription
nameatomUnique name for the event (e.g., :created, :updated, :cancelled)

Options

NameTypeDefaultRequiredDescription
trigger_onatom | [atom] | :manual-Action name(s) that trigger this event, or :manual for manual-only dispatch
moduleatomnilOptional callback module implementing AshDispatch.Event behaviour
event_idstringauto-generatedExplicit event ID. Auto-generated as {resource_name}.{event_name} if not specified
data_keyatom:recordKey to use for the resource in context.data
include_actor_asatomnilAlias key for the actor in context.data (see Actor Access)
manual_trigger_filterkeywordnilFilter for showing event in manual trigger UI (e.g., [confirmed_at: nil])
load[atom][]Relationships to preload before dispatching
domainatomnilEvent domain (e.g., :orders, :tickets). Defaults to resource domain
channels[keyword_list | map][]List of delivery channels for this event
contentkeyword_list | map[]Content configuration (subject, titles, messages)
metadatakeyword_list | map[]Event metadata (notification type, user configurable, etc.)
invalidates[string][]Frontend query keys to invalidate when notification is received

Examples

Simple inline event

dispatch do
  event :created,
    trigger_on: :create,
    channels: [
      [transport: :in_app, audience: :user],
      [transport: :email, audience: :user, delay: 300]
    ],
    content: [
      subject: "Order #{{order_number}} created",
      notification_title: "Order Created",
      notification_message: "Your order is being processed"
    ],
    metadata: [
      notification_type: :success,
      user_configurable: true
    ]
end

Event with callback module

For complex logic, use a callback module:

dispatch do
  event :created,
    trigger_on: :create_from_cart,
    module: MyApp.Events.Orders.Created,
    load: [:user, :items]
end

Multiple trigger actions

dispatch do
  event :status_changed,
    trigger_on: [:process, :complete, :cancel],
    channels: [
      [transport: :in_app, audience: :user]
    ],
    content: [
      notification_title: "Status: {{status}}"
    ]
end

Event with frontend cache invalidation

When the notification is received, the frontend can automatically invalidate TanStack Query caches or custom caches using the invalidates option:

dispatch do
  event :created,
    trigger_on: :create,
    channels: [
      [transport: :in_app, audience: :user]
    ],
    content: [
      notification_title: "Order Created",
      notification_message: "Your order #{{order_number}} is being processed"
    ],
    invalidates: ["orders", "order_stats"]
end

Custom event ID

dispatch do
  event :created,
    trigger_on: :create,
    event_id: "ecommerce.order.created",  # Custom ID for external integrations
    channels: [
      [transport: :webhook, audience: :external, webhook_url: "https://..."]
    ]
end

Event with actor alias

Use include_actor_as to give the actor (user who triggered the action) a semantic alias in context.data:

dispatch do
  # Invitation event - actor is the admin who invited the user
  event :invited,
    trigger_on: :invite,
    data_key: :invited_user,
    include_actor_as: :invited_by,
    module: MyApp.Accounts.Events.Invited,
    channels: [[transport: :email, audience: :user]]

  # Assignment event - actor is the person who assigned
  event :assigned,
    trigger_on: :assign,
    data_key: :ticket,
    include_actor_as: :assigned_by,
    channels: [[transport: :in_app, audience: :user]]
end

In your event module or templates, access the actor as:

  • context.data.actor (always available)
  • context.data.invited_by or context.data.assigned_by (semantic alias)

Manual-only events

Use trigger_on: :manual for events that are dispatched programmatically via AshDispatch.Dispatcher.dispatch/3 rather than automatically on actions. This is useful for:

  • Events triggered by external systems (AshAuthentication senders)
  • Events that need custom context not available in action changes
  • Events that should only be triggered from admin UI
dispatch do
  # Dispatched by AshAuthentication sender, not auto-triggered
  event :password_reset,
    trigger_on: :manual,
    data_key: :user,
    module: MyApp.Accounts.Events.PasswordReset.Event,
    channels: [[transport: :email, audience: :user]]

  # Only show in manual trigger UI for unconfirmed users
  event :email_confirmation,
    trigger_on: :manual,
    data_key: :user,
    manual_trigger_filter: [confirmed_at: nil],
    module: MyApp.Accounts.Events.EmailConfirmation.Event,
    channels: [[transport: :email, audience: :user]]
end

Manual events are still registered in EventRegistry for:

  • Preview in admin email template UI
  • Manual trigger functionality
  • TypeScript type generation

Channels Configuration

Channels define how and where the event is delivered.

Channel Fields

FieldTypeRequiredDescription
transportatomTransport type: :email, :in_app, :broadcast, :discord, :slack, :sms, :webhook
audienceatomWho receives this: :user, :admin, or custom audience
delayintegerDelay in seconds before delivering (default: 0)
policyatomDelivery policy: :always or :skip_if_read (default: :always)
variantatomTemplate variant (e.g., :admin for email.admin.html.heex)
localestringStatic locale override (e.g., "sv" - always use Swedish)
locale_fromatomField on record to extract locale from (e.g., :visitor_locale)
locales[string]Locales for template generation (defaults to resource/event locales)
webhook_urlstringWebhook URL (required for :webhook transport)
deduplicate_groupatomGroup channels for deduplication (see below)
optionalbooleanSuppress warnings when no recipients found (default: false)

Deduplication with deduplicate_group

When audiences overlap (e.g., :admin and :stakeholders both contain some users), a user might receive duplicate notifications. The deduplicate_group option lets you control this.

Channels sharing the same deduplicate_group are deduplicated - if a user matches multiple audiences in the same group, they receive only ONE notification. The first matching channel (by DSL order) wins.

channels: [
  # These two share a group - user in both gets only one in_app notification
  [transport: :in_app, audience: :stakeholders, deduplicate_group: :internal],
  [transport: :in_app, audience: :admin, deduplicate_group: :internal],

  # These share a different group - deduplication applies within this group
  [transport: :email, audience: :admin, deduplicate_group: :admin_emails],
  [transport: :email, audience: :finance, deduplicate_group: :admin_emails],

  # No group = no deduplication - customer always gets notification
  [transport: :in_app, audience: :customer]
]

Important notes:

  • Channels without deduplicate_group are never deduplicated
  • Deduplication is opt-in (off by default)
  • Order matters: first channel in DSL order wins when deduplicating
  • Groups are per-transport-agnostic: you can group :email and :in_app channels together if needed

Optional Channels

Use optional: true when an audience may legitimately have no recipients. This suppresses warnings that would otherwise be logged when recipient resolution returns empty.

Common use cases:

  • Dynamic audiences that may not exist yet (e.g., :lead_owner before a lead is assigned)
  • Conditional audiences based on workflow state
  • MFA-based audiences that may return empty lists in certain scenarios
channels: [
  # Primary notification - always has a recipient
  [transport: :in_app, audience: :user],

  # Optional - lead owner may not be assigned yet
  [transport: :in_app, audience: :lead_owner, optional: true],
  [transport: :email, audience: :lead_owner, optional: true],

  # Optional - KAMs may not exist in the system
  [transport: :in_app, audience: :kam, optional: true]
]

When optional: true is set, no warning is logged if:

  • The MFA resolver function doesn't exist for this audience
  • The MFA resolver returns an empty list (no recipients)
  • No recipient configuration is found for the audience

Without optional: true, you'll see warnings like:

When the resolver function returns empty (most common case):

[warning] [AshDispatch] Audience resolver for :lead_owner returned no recipients.

The resolver function was called successfully but returned an empty list.
This may be expected (e.g., no lead owner assigned yet).

Tip: To silence this warning, add `optional: true` to the channel:
     [transport: :in_app, audience: :lead_owner, optional: true]

When the resolver function doesn't exist:

[warning] [AshDispatch] Audience resolver function MyApp.AudienceResolver.lead_owner/1 not found for audience :lead_owner.

The function is not exported. Check that:
1. The module exists and is compiled
2. The function is public (def, not defp)
3. The arity matches the args in your config

Tip: If this audience is expected to have no recipients sometimes, add `optional: true` to the channel:
     [transport: :in_app, audience: :lead_owner, optional: true]

Channel Examples

channels: [
  # Immediate in-app notification
  [transport: :in_app, audience: :user],

  # Email after 5 minutes if notification not read
  [transport: :email, audience: :user, delay: 300, policy: :skip_if_read],

  # Immediate email to admins
  [transport: :email, audience: :admin],

  # Discord webhook to team channel
  [transport: :discord, audience: :team, webhook_url: "https://discord.com/api/webhooks/..."],

  # SMS to user (if enabled)
  [transport: :sms, audience: :user]
]

Channel Locale Examples

channels: [
  # Customer email - locale from their visitor_locale field
  [transport: :email, audience: :customer, locale_from: :visitor_locale],

  # Internal admin email - always in Swedish
  [transport: :email, audience: :admin, locale: "sv"],

  # Channel-specific locales for template generation
  [transport: :email, audience: :user, locales: ["sv", "en", "no"]],

  # Variant + locale (uses email.admin.sv.html.heex)
  [transport: :email, audience: :admin, variant: :admin, locale: "sv"]
]

Content Configuration

Content defines the message text for notifications and emails.

Content Fields

FieldTypeDescription
subjectstringEmail subject line. Supports {{variable}} interpolation
notification_titlestringIn-app notification title. Supports {{variable}} interpolation
notification_messagestringIn-app notification message. Supports {{variable}} interpolation
action_urlstringURL for notification action button. Supports {{variable}} interpolation

Variable Interpolation

Use {{variable_name}} syntax to insert dynamic values:

content: [
  subject: "Order #{{order_number}} - {{status}}",
  notification_title: "Hello {{user_name}}!",
  notification_message: "Created at {{inserted_at}}",
  action_url: "https://app.example.com/orders/{{id}}"
]

Variables are resolved from:

  1. Resource fields: Any attribute on the resource (e.g., {{id}}, {{status}})
  2. Preloaded relationships: Use load: [...] option
    • {{user.name}} becomes {{user_name}}
    • {{assignee.email}} becomes {{assignee_email}}
  3. Custom assigns: Via callback module's prepare_template_assigns/2

Content Examples

# Simple text
content: [
  notification_title: "Welcome!",
  notification_message: "Thanks for signing up"
]

# With variables
content: [
  subject: "Order #{{order_number}} shipped",
  notification_title: "Order Shipped!",
  notification_message: "Your order to {{shipping_address}} is on its way",
  action_url: "/orders/{{id}}/tracking"
]

# Complex with dates
content: [
  subject: "Ticket #{{id}} resolved",
  notification_message: "Resolved by {{assignee_name}} on {{resolved_at}}"
]

Metadata Configuration

Metadata provides additional context about the event.

Metadata Fields

FieldTypeDefaultDescription
notification_typeatom:infoType for UI styling: :info, :success, :warning, :error
action_requiredbooleanfalseWhether this notification requires user action
user_configurablebooleantrueWhether users can opt out via preferences

Metadata Examples

# Success notification that users can configure
metadata: [
  notification_type: :success,
  action_required: false,
  user_configurable: true
]

# Critical error that can't be disabled
metadata: [
  notification_type: :error,
  action_required: true,
  user_configurable: false
]

# Info notification
metadata: [
  notification_type: :info,
  user_configurable: true
]

Actor Access

When events are triggered by Ash actions, the actor (user who performed the action) is automatically included in context.data.

Default Behavior

The actor is always available as context.data.actor:

def prepare_template_assigns(context, _channel) do
  %{
    performed_by: context.data.actor.name,
    performer_email: context.data.actor.email
  }
end

Semantic Aliases with include_actor_as

For clearer code, use include_actor_as to add a semantic alias:

dispatch do
  event :invited,
    trigger_on: :invite,
    data_key: :invited_user,
    include_actor_as: :invited_by,
    channels: [[transport: :email, audience: :user]]
end

Now the actor is available as both:

  • context.data.actor (always)
  • context.data.invited_by (alias)

Common Actor Alias Patterns

Event TypeAliasDescription
Invitation:invited_byAdmin or owner who sent the invite
Assignment:assigned_byUser who assigned the task/ticket
Approval:approved_byUser who approved the request
Cancellation:cancelled_byUser who cancelled the order
Comment:commented_byUser who left the comment

Using Actor in Templates

In HEEx templates, access the actor via assigns:

<p>This invitation was sent by <%= @invited_by.name %>.</p>
<p>Contact them at <%= @invited_by.email %> if you have questions.</p>

In your event module's prepare_template_assigns/2:

def prepare_template_assigns(context, _channel) do
  %{
    invited_by: context.data.invited_by,
    invited_user: context.data.invited_user
  }
end

Callback Module

For complex events, implement the AshDispatch.Event behaviour:

Required Callbacks

@callback channels(context :: Context.t()) :: [Channel.t()]
@callback recipients(context :: Context.t(), channel :: Channel.t()) :: [User.t()]
@callback notification_title(context :: Context.t(), channel :: Channel.t()) :: String.t()
@callback notification_message(context :: Context.t(), channel :: Channel.t()) :: String.t()

Optional Callbacks

@callback from_email(context :: Context.t(), channel :: Channel.t()) :: String.t()
@callback subject(context :: Context.t(), channel :: Channel.t()) :: String.t()
@callback render_html_email(context :: Context.t(), channel :: Channel.t()) :: String.t()
@callback render_text_email(context :: Context.t(), channel :: Channel.t()) :: String.t()
@callback prepare_template_assigns(context :: Context.t(), channel :: Channel.t()) :: map()
@callback action_url(context :: Context.t(), channel :: Channel.t()) :: String.t() | nil
@callback metadata(context :: Context.t()) :: map()

Example Callback Module

defmodule MyApp.Events.Orders.Created do
  @behaviour AshDispatch.Event

  @impl true
  def channels(_context) do
    [
      %AshDispatch.Channel{transport: :in_app, audience: :user},
      %AshDispatch.Channel{transport: :email, audience: :user, time: {:in, 300}},
      %AshDispatch.Channel{transport: :email, audience: :admin}
    ]
  end

  @impl true
  def recipients(context, channel) do
    case channel.audience do
      :user -> [context.data.order.user]
      :admin -> MyApp.Accounts.list_admins()
    end
  end

  @impl true
  def from_email(_context, _channel), do: "orders@myapp.com"

  @impl true
  def subject(context, _channel) do
    "Order ##{context.data.order.number} created"
  end

  @impl true
  def notification_title(_context, _channel), do: "Order Created"

  @impl true
  def notification_message(context, _channel) do
    "Your order ##{context.data.order.number} is being processed"
  end

  @impl true
  def prepare_template_assigns(context, _channel) do
    order = context.data.order

    %{
      order_number: order.number,
      total: Money.to_string(order.total),
      item_count: length(order.items),
      user_name: order.user.name,
      action_url: "#{context.base_url}/orders/#{order.id}"
    }
  end

  @impl true
  def render_html_email(context, channel) do
    assigns = prepare_template_assigns(context, channel)

    Phoenix.View.render_to_string(
      MyAppWeb.EmailView,
      "order_created.html",
      assigns
    )
  end

  @impl true
  def render_text_email(context, channel) do
    assigns = prepare_template_assigns(context, channel)

    Phoenix.View.render_to_string(
      MyAppWeb.EmailView,
      "order_created.text",
      assigns
    )
  end
end

Auto-Dispatch Behavior

When you define events, AshDispatch automatically:

  1. Injects Change: Adds AshDispatch.Changes.DispatchEvent to the action's changes list
  2. Runs After Success: Change executes only if action succeeds
  3. Creates Receipt: DeliveryReceipt created with full content
  4. Dispatches: Each channel gets delivered via its transport
  5. Tracks Status: Receipt status updated through delivery lifecycle

No Manual Triggering Required

# This is all you need - AshDispatch handles the rest!
dispatch do
  event :created, trigger_on: :create, ...
end

# When you create a record:
ProductOrder
|> Ash.Changeset.for_create(:create, params)
|> Ash.create!()

# -> Event automatically dispatches!
# -> Receipts created
# -> Notifications sent
# -> Emails queued
# -> No manual dispatcher calls needed

Configuration Requirements

Required: Oban

AshDispatch uses Oban for async delivery. Configure queues:

# config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [
    ash_dispatch_email: 10,
    ash_dispatch_webhook: 5
  ]

Required for Email: Swoosh

# config/config.exs
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Resend,
  api_key: System.get_env("RESEND_API_KEY")

Optional: Retry Cron

Auto-retry failed deliveries:

# config/config.exs
config :my_app, Oban,
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"*/15 * * * *", MyApp.Workers.RetryFailedReceipts}
     ]}
  ]

Validation

AshDispatch validates your event configurations at compile time:

Validated at Compile Time

  • ✅ Event names are unique within resource
  • trigger_on actions exist in the resource
  • ✅ Events have either channels OR a module
  • ✅ Channel transport types are valid

Validation Errors

# Duplicate event names
** (Spark.Error.DslError) Duplicate event name: :created

# Non-existent action
** (Spark.Error.DslError) Event :created references non-existent action: :create_order
Available actions: [:create, :update, :destroy]

# Missing configuration
** (Spark.Error.DslError) Event :created has no configuration.
Events must have either:
- Inline channel/content configuration, OR
- A callback module via the `module:` option

Advanced Patterns

Conditional Channels

Use callback module for dynamic channel selection:

def channels(context) do
  base_channels = [
    %Channel{transport: :in_app, audience: :user}
  ]

  if urgent?(context.data.order) do
    base_channels ++ [%Channel{transport: :sms, audience: :user}]
  else
    base_channels ++ [%Channel{transport: :email, audience: :user, time: {:in, 3600}}]
  end
end

Multi-Audience Events

dispatch do
  event :created,
    trigger_on: :create,
    channels: [
      [transport: :in_app, audience: :user],
      [transport: :email, audience: :user, delay: 300, policy: :skip_if_read],
      [transport: :email, audience: :admin],
      [transport: :discord, audience: :team, webhook_url: "https://..."]
    ]
end

Progressive Notifications

dispatch do
  # Immediate notification
  event :created,
    trigger_on: :create,
    channels: [[transport: :in_app, audience: :user]]

  # Follow-up email if not completed
  event :reminder,
    trigger_on: :create,
    channels: [[transport: :email, audience: :user, delay: 86400]],  # 1 day
    content: [subject: "Don't forget to complete your order!"]
end

counters

The counters section defines real-time counter broadcasts that automatically update frontend UIs when actions occur.

Usage

defmodule MyApp.Orders.ProductOrder do
  use Ash.Resource,
    extensions: [AshDispatch.Resource]

  counters do
    counter :pending_orders,
      trigger_on: [:create, :complete, :cancel],
      query_filter: [status: :pending],
      audience: :user,
      group: :orders,
      invalidates: ["orders"]

    counter :admin_pending_orders,
      trigger_on: [:create, :complete, :cancel],
      query_filter: [status: :pending],
      audience: :admin,
      authorize?: false,
      invalidates: ["orders"]
  end
end

Counter Options

NameTypeDefaultRequiredDescription
trigger_onatom | [atom]-Action name(s) that trigger this counter broadcast
query_filterkeyword[]Static Ash filter for counting (e.g., [status: :pending])
audienceatom-Who receives broadcasts: :user, :admin, or custom audience
counter_nameatomsame as nameCounter name to broadcast (defaults to DSL name)
invalidates[string][]Frontend query keys to invalidate
groupatomnilCounter group for TypeScript generation
authorize?booleantrueWhether to use Ash authorization (policies)
scopeAsh.Expr.t()nilAsh expression for recipient-specific scoping
user_id_path[atom]auto-derivedPath to user_id for scoping (e.g., [:cart, :user_id])
filter_by_recordkeywordnilFilter by triggering record field
aggregateatomnilUse Ash aggregate instead of query_filter

Three-Layer Control Model

Counters use a three-layer control model for maximum flexibility:

LayerOptionPurpose
Audienceaudience: :adminWHO receives the broadcast
Authorizationauthorize?: falseWHAT records actor CAN see (Ash policies)
Scopingscope: expr(...)WHAT subset we WANT to count

Counter Examples

User Counter (Auto-Scoped)

counter :my_pending_orders,
  trigger_on: [:create, :complete],
  query_filter: [status: :pending],
  audience: :user,
  invalidates: ["orders"]

User counters automatically derive user_id_path from the resource's belongs_to :user relationship.

Admin Counter (System-Wide)

counter :admin_pending_orders,
  trigger_on: [:create, :complete],
  query_filter: [status: :pending],
  audience: :admin,
  authorize?: false,  # Bypass policies - count ALL records
  invalidates: ["orders", "analytics"]

Scoped Counter (Custom Expression)

# Regional admin sees only orders in their region
counter :regional_pending_orders,
  trigger_on: [:create, :complete],
  query_filter: [status: :pending],
  audience: :admin,
  scope: expr(region == ^actor(:region)),
  invalidates: ["orders"]

# Admin sees their assigned tickets
counter :my_assigned_tickets,
  trigger_on: [:create, :resolve],
  query_filter: [status: :open],
  audience: :admin,
  scope: expr(assigned_to_id == ^actor(:id)),
  invalidates: ["tickets"]

# Seller sees orders containing their products
counter :seller_orders,
  trigger_on: [:create, :complete],
  query_filter: [status: :pending],
  audience: :seller,
  scope: expr(exists(items, product.seller_id == ^actor(:id))),
  invalidates: ["orders"]

Nested Resource Counter

# CartItem → Cart → User (no direct user relationship)
counter :cart_items,
  trigger_on: [:add_to_cart, :remove_from_cart],
  query_filter: [],
  audience: :user,
  user_id_path: [:cart, :user_id],  # Explicit path
  invalidates: ["cart"]

Scope Expression Templates

The scope option accepts any Ash expression with ^actor(:field) templates:

^actor(:id)           # Recipient's ID
^actor(:region)       # Recipient's region attribute
^actor(:team_id)      # Recipient's team_id
^actor([:profile, :org_id])  # Nested path access

scope vs user_id_path

Featureuser_id_pathscope
Simple user_id[:user_id]expr(user_id == ^actor(:id))
Nested paths[:cart, :user_id]expr(cart.user_id == ^actor(:id))
Attribute matchingexpr(region == ^actor(:region))
Relationship traversalexpr(assigned_support.team_id == ^actor(:team_id))
exists/has_manyexpr(exists(items, ...))

Recommendation: Use user_id_path for simple cases, scope for complex filtering.


Localization

AshDispatch supports multi-language templates with dynamic locale resolution.

Quick Setup

  1. Configure locales at resource level:
dispatch do
  locales ["sv", "en"], locale_from: :visitor_locale

  event :created, trigger_on: :create do
    channels [[transport: :email, audience: :customer]]
  end
end
  1. Run mix ash.codegen to generate locale-specific templates:

    • email.html.heex (fallback)
    • email.sv.html.heex
    • email.en.html.heex
  2. At runtime, locale is resolved from record.visitor_locale

Locale Configuration Levels

LevelOptionPurpose
Resourcelocales ["sv", "en"]Default locales for all events
Eventlocales: ["sv", "en"]Override for specific event
Channellocale: "sv"Static locale for channel
Channellocale_from: :fieldDynamic locale from record field

Common Patterns

Different templates per audience:

channels: [
  # Customer gets their preferred language
  [transport: :email, audience: :customer, locale_from: :visitor_locale],
  # Internal staff always gets Swedish
  [transport: :email, audience: :admin, locale: "sv"]
]

Variant + locale combination:

# Uses email.customer.en.html.heex when visitor_locale is "en"
[transport: :email, audience: :customer, variant: :customer, locale_from: :visitor_locale]

See Localization Topic for complete guide.


See Also