# Getting Started with AshDispatch

This tutorial will walk you through adding event-driven notifications to an Ash resource using **inline events** (defined directly in the `dispatch` DSL).

**Looking for standalone event modules, manual triggers, or preview functionality?** See [Manual Dispatch and Event Modules](./manual-dispatch-and-events.md) for the complete guide on event modules, the two-path pattern, and admin-triggered events.

## Prerequisites

This guide assumes you're familiar with:
- [Ash Framework basics](https://hexdocs.pm/ash/get-started.html)
- Ash resources and actions
- Basic Elixir

## Installation

### 1. Add dependency

```elixir
# mix.exs
def deps do
  [
    {:ash_dispatch, "~> 0.1.0"},
    {:oban, "~> 2.17"},  # Required for async delivery
    {:swoosh, "~> 1.16"} # Required for email transport
  ]
end
```

**Note:** For complete configuration options including standalone event modules, user resources, and custom notification resources, see [Configuration Guide](../topics/configuration.md).

### 2. Configure Oban

AshDispatch uses Oban for asynchronous email delivery. See [Oban Configuration](../topics/oban-configuration.md) for complete setup.

```elixir
# config/config.exs
config :my_app, Oban,
  engine: Oban.Engines.Basic,
  notifier: Oban.Notifiers.Postgres,
  repo: MyApp.Repo,
  queues: [
    emails: 10  # AshDispatch uses :emails queue
  ]
```

Then add Oban to your supervision tree:

```elixir
# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyApp.Repo,
    {Oban, Application.fetch_env!(:my_app, Oban)},
    # ... other children
  ]

  Supervisor.start_link(children, opts: [strategy: :one_for_one])
end
```

### 3. Configure email transport (optional)

AshDispatch uses Swoosh for sending emails. Configure your Swoosh mailer:

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

Then configure AshDispatch to use your Swoosh mailer:

```elixir
# config/config.exs
config :ash_dispatch,
  otp_app: :my_app,  # Required for template layouts
  email_backend: AshDispatch.EmailBackend.Swoosh,
  swoosh_mailer: MyApp.Mailer
```

**Available Swoosh adapters:**
- **Resend** - `Resend.Swoosh.Adapter` (recommended)
- **SendGrid** - `Swoosh.Adapters.Sendgrid`
- **Postmark** - `Swoosh.Adapters.Postmark`
- **Mailgun** - `Swoosh.Adapters.Mailgun`
- **SMTP** - `Swoosh.Adapters.SMTP`
- **Local** (dev/test) - `Swoosh.Adapters.Local`

**For tests**, use the test adapter:

```elixir
# config/test.exs
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Test

config :ash_dispatch,
  email_backend: AshDispatch.EmailBackend.Swoosh,
  swoosh_mailer: MyApp.Mailer
```

If you don't configure an email backend, AshDispatch will log emails instead of sending them (useful for development).

### 4. Configure URL Builder (optional)

For automatic source URL generation on delivery receipts (linking receipts back to their source resources), configure a URL builder module:

```elixir
# config/config.exs
config :ash_dispatch,
  url_builder: MyApp.UrlBuilder
```

The URL builder should implement two functions:

```elixir
defmodule MyApp.UrlBuilder do
  @moduledoc """
  URL builder for AshDispatch source URLs and labels.

  Builds audience-specific URLs for resources and provides human-readable labels.
  """

  # Load paths at compile time
  @app_paths Application.compile_env(:my_app, :app_paths, %{})

  @doc """
  Returns a human-readable label for a resource type.

  Used by the `source_label` calculation on DeliveryReceipt to provide
  friendly labels for admin UIs.

  ## Examples

      resource_label(:order)  #=> "Order"
      resource_label(:ticket) #=> "Support Ticket"
  """
  def resource_label(:order), do: "Order"
  def resource_label(:ticket), do: "Support Ticket"
  def resource_label(:user), do: "Customer"
  def resource_label(_), do: nil

  @doc """
  Builds a URL for a resource with audience-specific routing.

  ## Parameters
  - `resource_type` - Atom like :order, :ticket (matches event's data_key)
  - `resource` - Map/struct with :id field
  - `opts` - Keyword list with :audience (required), :path_only (optional)

  ## Examples

      build_resource_url(:order, %{id: "abc"}, audience: :user, path_only: true)
      #=> "/orders/abc"

      build_resource_url(:order, %{id: "abc"}, audience: :admin)
      #=> "https://myapp.com/admin/orders/abc"
  """
  def build_resource_url(resource_type, resource, opts) do
    audience = Keyword.fetch!(opts, :audience)
    path_only = Keyword.get(opts, :path_only, false)

    path_template = get_in(@app_paths, [audience, resource_type])

    if is_nil(path_template) do
      raise ArgumentError,
        "No path configured for audience #{inspect(audience)}, " <>
        "resource #{inspect(resource_type)}"
    end

    path = String.replace(path_template, ":id", to_string(resource.id))

    if path_only do
      path
    else
      get_base_url() <> path
    end
  end

  defp get_base_url do
    # Implement based on your endpoint configuration
    "https://myapp.com"
  end
end
```

Configure your path templates:

```elixir
# config/config.exs
config :my_app, :app_paths,
  user: %{
    order: "/orders/:id",
    ticket: "/support/:id"
  },
  admin: %{
    order: "/admin/orders/:id",
    ticket: "/admin/tickets/:id"
  }
```

With this configured, events with `data_key` defined will automatically generate source URLs. The `data_key` (e.g., `:order`) maps directly to the `resource_type` in your path config.

**How it works:**
1. Event defines `data_key: :order`
2. Default `source_url/2` calls `url_builder.build_resource_url(:order, order, audience: channel.audience)`
3. URL builder looks up path template for `:user` or `:admin` audience
4. Returns audience-specific path like `/orders/abc` or `/admin/orders/abc`

Events without `data_key` or with paths not configured will return `nil` for source URL (graceful fallback).

### DeliveryReceipt Calculated Fields

With the URL builder configured, `DeliveryReceipt` provides three calculated fields:

| Field | Description |
|-------|-------------|
| `source_url` | URL using the receipt's audience (`:user` → portal, `:admin` → admin) |
| `source_label` | Human-readable label from `resource_label/1` (e.g., "Order", "Ticket") |
| `admin_url` | Always returns admin URL, regardless of receipt audience |

**Frontend usage example (TypeScript):**

```typescript
// In your admin dashboard, always use adminUrl for links
<Link href={receipt.adminUrl}>
  View {receipt.sourceLabel}
</Link>

// In user portal, use sourceUrl (audience-aware)
<Link href={receipt.sourceUrl}>
  View {receipt.sourceLabel}
</Link>
```

**Why `admin_url`?** When viewing receipts in an admin dashboard, you want admin links even for receipts that were sent to users (which have `audience: :user`). The `admin_url` calculation always uses `audience: :admin` when building the URL.

## Add Your First Event

Let's add notifications to a `Ticket` resource.

### 0. Initial Setup (first time only)

Before creating your first event, set up the directory structure and layouts:

```bash
mix ash_dispatch.setup
```

This creates `priv/ash_dispatch/layouts/` with default email templates. Customize these with your branding. See [Generator Guide](../topics/generator.md) for details.

### 1. Add the extension

```elixir
defmodule MyApp.Tickets.Ticket do
  use Ash.Resource,
    domain: MyApp.Tickets,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshDispatch.Resource]  # Add this!

  # ... existing code ...
end
```

### 2. Define your first event

```elixir
defmodule MyApp.Tickets.Ticket do
  use Ash.Resource,
    domain: MyApp.Tickets,
    extensions: [AshDispatch.Resource]

  actions do
    create :create do
      accept [:title, :description, :user_id]
    end
  end

  # Add dispatch section
  dispatch do
    event :created,
      trigger_on: :create,
      channels: [
        [transport: :in_app, audience: :user]
      ],
      content: [
        notification_title: "Ticket Created",
        notification_message: "Your ticket has been created and assigned ID #{{id}}"
      ],
      metadata: [
        notification_type: :success
      ]
  end
end
```

### 3. Test it out!

```elixir
# Create a ticket
ticket = Ticket
  |> Ash.Changeset.for_create(:create, %{
    title: "Bug report",
    description: "Something is broken",
    user_id: user.id
  })
  |> Ash.create!()

# Check that notification was created
notifications = Notification
  |> Ash.Query.filter(user_id == ^user.id)
  |> Ash.read!()

# You should see:
# [%Notification{
#   title: "Ticket Created",
#   message: "Your ticket has been created and assigned ID #1",
#   notification_type: :success
# }]
```

### 4. Check delivery receipts

Every event creates a `DeliveryReceipt` for tracking:

```elixir
require Ash.Query
import Ash.Expr

# Query receipts for this event
receipts = AshDispatch.Resources.DeliveryReceipt
  |> Ash.Query.filter(expr(event_id == "ticket.created"))
  |> Ash.read!()

# You should see:
# [%AshDispatch.Resources.DeliveryReceipt{
#   id: "a1b2c3d4-...",
#   event_id: "ticket.created",
#   transport: :in_app,
#   audience: :user,
#   recipient: "user-id-...",
#   status: :sent,
#   sent_at: ~U[2025-01-16 10:30:00Z],
#   content: %{
#     title: "Ticket Created",
#     message: "Your ticket has been created and assigned ID #1",
#     notification_type: :success
#   }
# }]

# Query failed deliveries
failed = AshDispatch.Resources.DeliveryReceipt
  |> Ash.Query.filter(expr(status == :failed))
  |> Ash.read!()

# Query by transport
email_receipts = AshDispatch.Resources.DeliveryReceipt
  |> Ash.Query.filter(expr(transport == :email))
  |> Ash.Query.sort(inserted_at: :desc)
  |> Ash.read!()
```

**Receipt Status Lifecycle:**
- `:pending` - Just created
- `:scheduled` - Oban job enqueued (for async transports)
- `:sending` - Currently being delivered
- `:sent` - Successfully delivered ✅
- `:failed` - Delivery failed (will retry)
- `:skipped` - Intentionally skipped (e.g., user opted out)

## Add Email Notifications

Let's send an email when a ticket is resolved.

### 1. Create email templates

```elixir
# lib/my_app/emails/templates/ticket_resolved.html.heex
<h1>Ticket Resolved</h1>

<p>Hi <%= @user_name %>,</p>

<p>Your ticket <strong>#<%= @ticket_id %></strong> has been resolved!</p>

<p><%= @resolution_notes %></p>

<p>
  <a href="<%= @action_url %>">View Ticket</a>
</p>
```

```elixir
# lib/my_app/emails/templates/ticket_resolved.text.eex
Ticket Resolved

Hi <%= @user_name %>,

Your ticket #<%= @ticket_id %> has been resolved!

<%= @resolution_notes %>

View Ticket: <%= @action_url %>
```

### 2. Add the event with callback module

For complex events with templates, use a callback module:

```elixir
dispatch do
  # ... existing :created event ...

  event :resolved,
    trigger_on: :resolve,
    module: MyApp.Events.Tickets.Resolved
end
```

### 3. Create the event module

```elixir
defmodule MyApp.Events.Tickets.Resolved do
  @behaviour AshDispatch.Event

  @impl true
  def channels(_context) do
    [
      [transport: :in_app, audience: :user],
      [transport: :email, audience: :user, delay: 60]
    ]
  end

  @impl true
  def recipients(context, channel) do
    case channel.audience do
      :user -> [context.data.ticket.user]
    end
  end

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

  @impl true
  def subject(_context, _channel), do: "Your ticket has been resolved"

  @impl true
  def prepare_template_assigns(context, _channel) do
    %{
      user_name: context.data.ticket.user.name,
      ticket_id: context.data.ticket.id,
      resolution_notes: context.data.ticket.resolution_notes,
      action_url: "#{context.base_url}/tickets/#{context.data.ticket.id}"
    }
  end

  @impl true
  def render_html_email(context, channel) do
    Phoenix.View.render_to_string(
      MyAppWeb.EmailView,
      "ticket_resolved.html",
      prepare_template_assigns(context, channel)
    )
  end

  @impl true
  def render_text_email(context, channel) do
    Phoenix.View.render_to_string(
      MyAppWeb.EmailView,
      "ticket_resolved.text",
      prepare_template_assigns(context, channel)
    )
  end

  @impl true
  def notification_title(_context, _channel), do: "Ticket Resolved"

  @impl true
  def notification_message(context, _channel) do
    "Ticket ##{context.data.ticket.id} has been resolved"
  end
end
```

## Add Multiple Triggers

Events can be triggered by multiple actions:

```elixir
dispatch do
  event :status_changed,
    trigger_on: [:start, :pause, :resume, :resolve, :close],
    channels: [
      [transport: :in_app, audience: :user]
    ],
    content: [
      notification_title: "Ticket Status Updated",
      notification_message: "Ticket #{{id}} status: {{status}}"
    ]
end
```

## Add Admin Notifications

Send notifications to different audiences:

```elixir
dispatch do
  event :created,
    trigger_on: :create,
    channels: [
      # User gets in-app notification
      [transport: :in_app, audience: :user],

      # Admins get email immediately
      [transport: :email, audience: :admin],

      # User gets email after 5 minutes (if not read)
      [transport: :email, audience: :user, delay: 300, policy: :skip_if_read]
    ],
    content: [
      subject: "New Ticket: {{title}}",
      notification_title: "New Ticket",
      notification_message: "{{user_name}} created ticket #{{id}}"
    ]
end
```

## Preload Relationships

If your event needs related data, preload it:

```elixir
dispatch do
  event :created,
    trigger_on: :create,
    load: [:user, :assignee],  # Preload these relationships
    channels: [
      [transport: :email, audience: :user]
    ],
    content: [
      subject: "Ticket assigned to {{assignee_name}}"
    ]
end
```

## Variable Interpolation

AshDispatch automatically replaces `{{variable}}` placeholders with actual values from your resource data.

### Basic Usage

Use double curly braces in any inline content field:

```elixir
content: [
  subject: "Ticket #{{id}} - {{status}}",
  notification_title: "Hello {{user_name}}!",
  notification_message: "Created at {{inserted_at}}",
  action_url: "/tickets/{{id}}"
]
```

### Variable Sources

Variables can come from three sources:

**1. Direct Attributes** - Fields on the resource itself:
```elixir
# In Ticket resource with fields: id, status, title, priority
content: [
  notification_message: "Ticket #{{id}}: {{title}} (Priority: {{priority}})"
]
# → "Ticket #123: Bug report (Priority: high)"
```

**2. Nested Attributes** - Preloaded relationships using dot notation:
```elixir
dispatch do
  event :assigned,
    trigger_on: :assign,
    load: [:user, :assignee],  # Preload these first!
    content: [
      notification_message: "{{user.name}} assigned ticket to {{assignee.email}}"
    ]
end
# → "Alice assigned ticket to bob@example.com"
```

**3. Flattened Keys** - Alternative syntax for nested attributes:
```elixir
# These are equivalent:
notification_message: "Hello {{user.name}}"
notification_message: "Hello {{user_name}}"  # Underscore becomes dot

# Useful for deeply nested data:
notification_message: "Org: {{organization.billing_contact.email}}"
notification_message: "Org: {{organization_billing_contact_email}}"
```

### Type Conversion

Values are automatically converted to strings:

```elixir
# Atoms
{{status}} where status = :active → "active"

# Numbers
{{id}} where id = 123 → "123"
{{price}} where price = 99.99 → "99.99"

# Booleans
{{active}} where active = true → "true"

# Dates & Times
{{inserted_at}} where inserted_at = ~U[2025-01-16 10:30:00Z]
→ "2025-01-16 10:30:00Z"

# Nil values
{{missing}} where missing = nil → "" (empty string)
```

### Safety Features

**Missing Variables** - No errors, just empty strings:
```elixir
notification_message: "Value: {{nonexistent_field}}"
# → "Value: " (empty)
```

**Nil Handling** - Gracefully handled:
```elixir
notification_message: "User: {{user.name}}"
# If user is nil → "User: "
# If user.name is nil → "User: "
```

**Missing Relationships** - Must be preloaded:
```elixir
# ❌ Will return empty if not preloaded:
notification_message: "{{user.name}}"

# ✅ Preload first:
dispatch do
  event :created,
    trigger_on: :create,
    load: [:user],  # Preload here!
    content: [
      notification_message: "{{user.name}} created a ticket"
    ]
end
```

### Common Patterns

**Dynamic URLs:**
```elixir
content: [
  action_url: "/tickets/{{id}}/comments/{{comment_id}}"
]
```

**Conditional-like Content:**
```elixir
# You can't use real conditionals, but you can use multiple events:
event :ticket_urgent,
  trigger_on: :create,
  channels: [[transport: :email, audience: :admin]],
  content: [
    subject: "🚨 URGENT: Ticket #{{id}} - {{title}}"
  ]

event :ticket_normal,
  trigger_on: :create,
  channels: [[transport: :in_app, audience: :user]],
  content: [
    subject: "Ticket #{{id}} - {{title}}"
  ]
```

**Multi-level Nesting:**
```elixir
dispatch do
  event :order_shipped,
    trigger_on: :ship,
    load: [:user, :shipping_address, :items],
    content: [
      notification_message: """
      Hi {{user.name}},

      Your order #{{id}} has shipped to:
      {{shipping_address.street}}
      {{shipping_address.city}}, {{shipping_address.state}}

      Items: {{items}} (note: lists don't interpolate well)
      """
    ]
end
```

### Troubleshooting

**Variable shows as `{{name}}` instead of value:**
- ✅ Check spelling (variables are case-sensitive)
- ✅ Ensure field exists on resource
- ✅ For nested fields, verify relationship is in `load: [...]`
- ✅ Check that relationship loaded successfully (not `%Ecto.Association.NotLoaded{}`)

**Variable shows as empty string:**
- This means the value is `nil` or the field doesn't exist
- Add `load: [:relationship]` if accessing nested data
- Verify the field name matches exactly (case-sensitive)

## User Preferences

Let users control which notifications they receive by implementing preference checking.

### 1. Configure Your Preference Checker

```elixir
# config/config.exs
config :ash_dispatch,
  user_preference: MyApp.NotificationPreferences
```

### 2. Implement the Behaviour

```elixir
defmodule MyApp.NotificationPreferences do
  @behaviour AshDispatch.UserPreference

  @impl true
  def user_allows?(user_id, _event_id, transport, opts) do
    category = opts[:category]

    case Ash.get(UserPreference, user_id) do
      {:ok, prefs} ->
        # Check if user disabled this category or transport
        category not in prefs.disabled_categories and
        transport not in prefs.disabled_transports

      _ ->
        true  # Allow if no preferences found
    end
  end
end
```

### 3. Add Categories to Events

```elixir
dispatch do
  event :promotional_offer,
    trigger_on: :create,
    channels: [[transport: :email, audience: :user]],
    metadata: [
      category: :marketing  # Users can opt out of this
    ]
end
```

### How Preference Checking Works

1. **Receipt Created** - Delivery receipt created with status `:pending`
2. **Preference Check** - Transport calls `UserPreference.allows?/3`
3. **If Opted Out** - Receipt marked `:skipped` with error `"user_opted_out"`
4. **If Allowed** - Delivery proceeds normally

**Important:** Receipts are always created for audit purposes, even if skipped.

### Preference Granularity

Users can control at three levels:

**By Category:**
```elixir
# User opts out of all marketing
disabled_categories: [:marketing, :promotional]
```

**By Transport:**
```elixir
# User opts out of emails only
disabled_transports: [:email]  # Still gets :in_app
```

**Combined:**
```elixir
# User opts out of marketing emails but allows marketing in-app
def user_allows?(user_id, _event_id, transport, opts) do
  category = opts[:category]

  # Check category + transport combinations
  {category, transport} not in get_disabled_combinations(user_id)
end
```

### Which Events Are Checked?

**✅ Preference checking applies to:**
- Events with `audience: :user`
- User-configurable events

**❌ Preferences are bypassed for:**
- Events with `audience: :admin`
- Events with `audience: :team`
- Events with `audience: :system`
- Critical system notifications

### Testing Preference Checking

```elixir
test "user who opted out gets notification skipped" do
  user = create_opted_out_user()

  {:ok, order} = create_order(%{user_id: user.id})

  # Verify receipt was skipped
  receipts = DeliveryReceipt
    |> Ash.Query.filter(event_id == "order.created")
    |> Ash.read!()

  assert hd(receipts).status == :skipped
  assert hd(receipts).error_message == "user_opted_out"
end
```

See [User Preferences](../topics/user-preferences.md) for complete documentation including UI integration, caching, and advanced patterns.

## Testing Events

### Test that events are defined

```elixir
defmodule MyApp.TicketTest do
  use ExUnit.Case

  alias MyApp.Tickets.Ticket

  test "has dispatch events defined" do
    dsl_state = Ticket.spark_dsl_config()
    events = Spark.Dsl.Transformer.get_entities(dsl_state, [:dispatch])

    assert length(events) == 2
    assert Enum.find(events, &(&1.name == :created))
    assert Enum.find(events, &(&1.name == :resolved))
  end
end
```

### Test event dispatch

```elixir
test "creating ticket dispatches event" do
  ticket = create_ticket()

  # Check that notification was created
  notifications = Notification
    |> Ash.Query.filter(user_id == ^ticket.user_id)
    |> Ash.read!()

  assert [notification] = notifications
  assert notification.title == "Ticket Created"
end
```

### Test email content with factories

```elixir
test "resolved email renders correctly" do
  # Use factory to build test data (no database!)
  ticket = build(:ticket, %{
    id: 123,
    user: build(:user, %{name: "Alice"}),
    resolution_notes: "Fixed the bug"
  })

  context = %AshDispatch.Context{
    event_id: "ticket.resolved",
    data: %{ticket: ticket},
    base_url: "https://app.example.com"
  }

  channel = %AshDispatch.Channel{transport: :email, audience: :user}

  html = MyApp.Events.Tickets.Resolved.render_html_email(context, channel)

  assert html =~ "Hi Alice"
  assert html =~ "Ticket #123"
  assert html =~ "Fixed the bug"
end
```

## Next Steps

**Recommended next:** [App Integration](../topics/app-integration.md) - Set up custom resources, database, and RPC

Then explore:
- [Phoenix Integration](../topics/phoenix-integration.md) - Real-time channels and frontend
- [Counter Broadcasting](../topics/counter-broadcasting.md) - Live UI counters
- [User Preferences](../topics/user-preferences.md) - Let users control notifications
- [DSL Reference](../dsls/DSL-AshDispatch-Resource.md) - Complete DSL documentation

## Common Patterns

### Delayed Email After In-App

Send in-app notification immediately, email if not read:

```elixir
channels: [
  [transport: :in_app, audience: :user],
  [transport: :email, audience: :user, delay: 300, policy: :skip_if_read]
]
```

### Admin Alerts

```elixir
channels: [
  [transport: :email, audience: :admin],
  [transport: :discord, audience: :team, webhook_url: "https://..."]
]
```

### Progressive Reminders

```elixir
# Define multiple events with increasing delays
event :payment_due_soon,
  trigger_on: :create,
  channels: [[transport: :email, audience: :user, delay: 86400]]  # 1 day

event :payment_overdue,
  trigger_on: :create,
  channels: [[transport: :email, audience: :user, delay: 259200]]  # 3 days
```

## Troubleshooting

### Events not firing

1. Check that extension is added: `extensions: [AshDispatch.Resource]`
2. Verify action name matches `trigger_on`
3. Check Oban is running: `Oban.check_queue(queue: :ash_dispatch_email)`

### Emails not sending

1. Verify Swoosh is configured
2. Check Oban job logs
3. Look for delivery receipts with `:failed` status

### Variables not interpolating

1. Ensure variable exists on the resource
2. Preload relationships with `load: [...]`
3. Check spelling (case-sensitive!)

## Help & Support

- [GitHub Issues](https://github.com/magasin/ash_dispatch/issues)
- [Ash Community](https://ash-hq.org)
- [Documentation](../README.md)
