# DSL: AshDispatch.Resource
The `AshDispatch.Resource` extension adds event dispatching capabilities to Ash resources.
## Table of Contents
- [dispatch](#dispatch) - Top-level section for defining events
- [event](#dispatch-event) - Define an event that dispatches when actions occur
- [locales](#dispatch-locales) - Configure locales for multi-language templates
- [Actor Access](#actor-access) - Accessing the actor (user who triggered the action) in events
- [Localization](#localization) - Multi-language template support
---
## dispatch
The `dispatch` section contains event definitions for the resource.
### Usage
```elixir
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
- [event](#dispatch-event)
- [locales](#dispatch-locales)
---
## dispatch.locales
Configures locales for multi-language template generation and runtime locale resolution.
### Arguments
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `locales` | `[String.t()]` | ✅ | List of locale codes (e.g., `["sv", "en", "no"]`) |
### Options
| Name | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| `default_locale` | `String.t()` | config default | ❌ | Fallback locale when none can be determined from record |
| `locale_from` | `atom` | `nil` | ❌ | Field on resource to extract runtime locale from |
### Examples
#### Resource-level locale configuration
```elixir
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
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `name` | `atom` | ✅ | Unique name for the event (e.g., `:created`, `:updated`, `:cancelled`) |
### Options
| Name | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| `trigger_on` | `atom \| [atom] \| :manual` | - | ✅ | Action name(s) that trigger this event, or `:manual` for manual-only dispatch |
| `module` | `atom` | `nil` | ❌ | Optional callback module implementing `AshDispatch.Event` behaviour |
| `event_id` | `string` | auto-generated | ❌ | Explicit event ID. Auto-generated as `{resource_name}.{event_name}` if not specified |
| `data_key` | `atom` | `:record` | ❌ | Key to use for the resource in context.data |
| `include_actor_as` | `atom` | `nil` | ❌ | Alias key for the actor in context.data (see [Actor Access](#actor-access)) |
| `manual_trigger_filter` | `keyword` | `nil` | ❌ | Filter for showing event in manual trigger UI (e.g., `[confirmed_at: nil]`) |
| `load` | `[atom]` | `[]` | ❌ | Relationships to preload before dispatching |
| `domain` | `atom` | `nil` | ❌ | Event domain (e.g., `:orders`, `:tickets`). Defaults to resource domain |
| `channels` | `[keyword_list \| map]` | `[]` | ❌ | List of delivery channels for this event |
| `content` | `keyword_list \| map` | `[]` | ❌ | Content configuration (subject, titles, messages) |
| `metadata` | `keyword_list \| map` | `[]` | ❌ | Event metadata (notification type, user configurable, etc.) |
| `invalidates` | `[string]` | `[]` | ❌ | Frontend query keys to invalidate when notification is received |
### Examples
#### Simple inline event
```elixir
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:
```elixir
dispatch do
event :created,
trigger_on: :create_from_cart,
module: MyApp.Events.Orders.Created,
load: [:user, :items]
end
```
#### Multiple trigger actions
```elixir
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:
```elixir
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
```elixir
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`:
```elixir
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
```elixir
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
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `transport` | `atom` | ✅ | Transport type: `:email`, `:in_app`, `:broadcast`, `:discord`, `:slack`, `:sms`, `:webhook` |
| `audience` | `atom` | ✅ | Who receives this: `:user`, `:admin`, or custom audience |
| `delay` | `integer` | ❌ | Delay in seconds before delivering (default: 0) |
| `policy` | `atom` | ❌ | Delivery policy: `:always` or `:skip_if_read` (default: `:always`) |
| `variant` | `atom` | ❌ | Template variant (e.g., `:admin` for `email.admin.html.heex`) |
| `locale` | `string` | ❌ | Static locale override (e.g., `"sv"` - always use Swedish) |
| `locale_from` | `atom` | ❌ | Field on record to extract locale from (e.g., `:visitor_locale`) |
| `locales` | `[string]` | ❌ | Locales for template generation (defaults to resource/event locales) |
| `webhook_url` | `string` | ❌ | Webhook URL (required for `:webhook` transport) |
| `deduplicate_group` | `atom` | ❌ | Group channels for deduplication (see below) |
| `optional` | `boolean` | ❌ | Suppress 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.
```elixir
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
```elixir
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
```elixir
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
```elixir
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
| Field | Type | Description |
|-------|------|-------------|
| `subject` | `string` | Email subject line. Supports `{{variable}}` interpolation |
| `notification_title` | `string` | In-app notification title. Supports `{{variable}}` interpolation |
| `notification_message` | `string` | In-app notification message. Supports `{{variable}}` interpolation |
| `action_url` | `string` | URL for notification action button. Supports `{{variable}}` interpolation |
### Variable Interpolation
Use `{{variable_name}}` syntax to insert dynamic values:
```elixir
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
```elixir
# 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
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `notification_type` | `atom` | `:info` | Type for UI styling: `:info`, `:success`, `:warning`, `:error` |
| `action_required` | `boolean` | `false` | Whether this notification requires user action |
| `user_configurable` | `boolean` | `true` | Whether users can opt out via preferences |
### Metadata Examples
```elixir
# 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`:
```elixir
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:
```elixir
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 Type | Alias | Description |
|------------|-------|-------------|
| Invitation | `:invited_by` | Admin or owner who sent the invite |
| Assignment | `:assigned_by` | User who assigned the task/ticket |
| Approval | `:approved_by` | User who approved the request |
| Cancellation | `:cancelled_by` | User who cancelled the order |
| Comment | `:commented_by` | User who left the comment |
### Using Actor in Templates
In HEEx templates, access the actor via assigns:
```heex
<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`:
```elixir
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
```elixir
@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
```elixir
@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
```elixir
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
```elixir
# 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:
```elixir
# config/config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [
ash_dispatch_email: 10,
ash_dispatch_webhook: 5
]
```
### Required for Email: Swoosh
```elixir
# 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:
```elixir
# 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
```elixir
# 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:
```elixir
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
```elixir
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
```elixir
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
```elixir
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
| Name | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| `trigger_on` | `atom \| [atom]` | - | ✅ | Action name(s) that trigger this counter broadcast |
| `query_filter` | `keyword` | `[]` | ❌ | Static Ash filter for counting (e.g., `[status: :pending]`) |
| `audience` | `atom` | - | ✅ | Who receives broadcasts: `:user`, `:admin`, or custom audience |
| `counter_name` | `atom` | same as name | ❌ | Counter name to broadcast (defaults to DSL name) |
| `invalidates` | `[string]` | `[]` | ❌ | Frontend query keys to invalidate |
| `group` | `atom` | `nil` | ❌ | Counter group for TypeScript generation |
| `authorize?` | `boolean` | `true` | ❌ | Whether to use Ash authorization (policies) |
| `scope` | `Ash.Expr.t()` | `nil` | ❌ | Ash expression for recipient-specific scoping |
| `user_id_path` | `[atom]` | auto-derived | ❌ | Path to user_id for scoping (e.g., `[:cart, :user_id]`) |
| `filter_by_record` | `keyword` | `nil` | ❌ | Filter by triggering record field |
| `aggregate` | `atom` | `nil` | ❌ | Use Ash aggregate instead of query_filter |
### Three-Layer Control Model
Counters use a three-layer control model for maximum flexibility:
| Layer | Option | Purpose |
|-------|--------|---------|
| **Audience** | `audience: :admin` | WHO receives the broadcast |
| **Authorization** | `authorize?: false` | WHAT records actor CAN see (Ash policies) |
| **Scoping** | `scope: expr(...)` | WHAT subset we WANT to count |
### Counter Examples
#### User Counter (Auto-Scoped)
```elixir
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)
```elixir
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)
```elixir
# 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
```elixir
# 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:
```elixir
^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
| Feature | `user_id_path` | `scope` |
|---------|----------------|---------|
| Simple user_id | ✅ `[:user_id]` | ✅ `expr(user_id == ^actor(:id))` |
| Nested paths | ✅ `[:cart, :user_id]` | ✅ `expr(cart.user_id == ^actor(:id))` |
| Attribute matching | ❌ | ✅ `expr(region == ^actor(:region))` |
| Relationship traversal | ❌ | ✅ `expr(assigned_support.team_id == ^actor(:team_id))` |
| exists/has_many | ❌ | ✅ `expr(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:
```elixir
dispatch do
locales ["sv", "en"], locale_from: :visitor_locale
event :created, trigger_on: :create do
channels [[transport: :email, audience: :customer]]
end
end
```
2. Run `mix ash.codegen` to generate locale-specific templates:
- `email.html.heex` (fallback)
- `email.sv.html.heex`
- `email.en.html.heex`
3. At runtime, locale is resolved from `record.visitor_locale`
### Locale Configuration Levels
| Level | Option | Purpose |
|-------|--------|---------|
| **Resource** | `locales ["sv", "en"]` | Default locales for all events |
| **Event** | `locales: ["sv", "en"]` | Override for specific event |
| **Channel** | `locale: "sv"` | Static locale for channel |
| **Channel** | `locale_from: :field` | Dynamic locale from record field |
### Common Patterns
**Different templates per audience:**
```elixir
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:**
```elixir
# Uses email.customer.en.html.heex when visitor_locale is "en"
[transport: :email, audience: :customer, variant: :customer, locale_from: :visitor_locale]
```
See [Localization Topic](../topics/localization.md) for complete guide.
---
## See Also
- [Getting Started Tutorial](../tutorials/getting-started.md)
- [What is AshDispatch?](../topics/what-is-ash-dispatch.md)
- [Localization (i18n)](../topics/localization.md)
- [Phoenix Integration](../topics/phoenix-integration.md)
- [Counter Broadcasting](../topics/counter-broadcasting.md)