Skip to main content

lib/documentation/topics/generator.md

# Generators & Setup

AshDispatch provides generators to integrate with your application. Use `mix ash_dispatch.setup` once for initial setup, then define events in your resource DSL and run `mix ash_dispatch.gen` to generate missing files.

## Quick Start

```bash
# Initial setup (creates layouts and directory structure)
mix ash_dispatch.setup

# Generate missing templates, event modules, and TypeScript types
mix ash_dispatch.gen

# Or use the unified Ash codegen command
mix ash.codegen
```

---

## Setup Task

Run once when first integrating AshDispatch:

```bash
mix ash_dispatch.setup
```

### What It Creates

```
priv/ash_dispatch/
├── layouts/
│   ├── email.html.heex    # Your branded email layout
│   └── email.text.eex     # Plain text email layout
└── templates/             # Your event templates go here
```

### Customizing Layouts

After setup, edit the layouts to match your brand:

```heex
<!-- priv/ash_dispatch/layouts/email.html.heex -->
<!DOCTYPE html>
<html>
  <head><title><%= @subject %></title></head>
  <body>
    <!-- Your header with logo, brand colors -->
    <header>
      <img src="your-logo.png" />
      <h1><%= @subject %></h1>
    </header>

    <!-- Event content injected here -->
    <%= @inner_content %>

    <!-- Your footer with contact info -->
    <footer>
      Your Company | support@example.com
    </footer>
  </body>
</html>
```

All event templates will automatically use this layout.

---

## Defining Events in DSL

Events are defined directly in your resource using the `dispatch do` block:

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

  dispatch do
    event :created do
      module MyApp.Orders.Events.Created.Event
      trigger_on [:create]
      data_key :order

      channels do
        channel :in_app, :user,
          title: "Order skapad",
          message: "Din order har registrerats"
        channel :email, :user,
          subject: "Din order har skapats"
        channel :email, :admin,
          variant: :admin,
          subject: "Ny order inkommit"
      end
    end
  end
end
```

### DSL Options

| Option | Description |
|--------|-------------|
| `module` | Event module that handles template assigns and recipients |
| `trigger_on` | Actions that trigger this event (`:create`, `:update`, etc.) |
| `data_key` | Key used for the resource in template assigns |
| `channels` | List of delivery channels (in-app, email, SMS, etc.) |

### Channel Options

| Option | Description |
|--------|-------------|
| `:in_app` | Creates a notification in the database |
| `:email` | Sends an email using templates |
| `audience` | `:user`, `:admin`, or `:system` |
| `variant` | Template variant (e.g., `:admin` uses `email.admin.html.heex`) |
| `title` | In-app notification title |
| `message` | In-app notification message |
| `subject` | Email subject line |

## Generating Missing Files

After defining events in DSL, run the generator to create missing templates and event modules:

```bash
# Generate all missing files
mix ash_dispatch.gen

# Or via unified Ash codegen
mix ash.codegen

# Preview what would be generated
mix ash_dispatch.gen --dry-run

# CI check - fail if files need generation
mix ash_dispatch.gen --check
```

### What Gets Generated

For each event with an `:email` channel, the generator creates:

```
lib/my_app/orders/events/created/
├── event.ex                 # Event module (if module option specified but file missing)
└── templates/
    ├── email.html.heex      # HTML email content
    ├── email.text.eex       # Plain text email content
    ├── email.admin.html.heex  # If variant: :admin channel exists
    └── email.admin.text.eex
```

### Content-Only Templates

Templates only contain event-specific content. The layout (from `priv/ash_dispatch/layouts/`) handles structure:

```heex
<!-- lib/my_app/orders/events/created/templates/email.html.heex -->
<p>Hej <strong><%= @display_name %></strong>,</p>

<p>Tack för din beställning!</p>

<div style="background: #f0f9ff; padding: 20px;">
  <h2>Orderdetaljer</h2>
  <p>Ordernummer: <%= @order_number %></p>
</div>

<a href={@source_url}>Visa order →</a>
```

No DOCTYPE, html, head, body, header, or footer needed - the layout provides all that.

---

## Transport-Specific Layouts

Layouts are discovered automatically by transport name. Each transport can have its own layout and format.

### How It Works

```
priv/ash_dispatch/layouts/
├── email.html.heex     # HTML layout for :email transport
├── email.text.eex      # Text layout for :email transport
├── sms.text.eex        # Text layout for :sms transport (if added)
└── discord.md.eex      # Markdown layout for :discord transport (if added)
```

The template resolver looks for: `{transport}.{extension}` where extension comes from format.

### Format Extensions

Configure custom formats for new transports:

```elixir
# config/config.exs
config :ash_dispatch,
  format_extensions: %{
    # Defaults (already included):
    # html: "html.heex",
    # text: "text.eex",

    # Custom formats:
    markdown: "md.eex"     # For Discord/Slack
    # json: "json.eex"     # For webhooks
  }
```

### Adding a New Transport (e.g., SMS)

1. **Create layout** at `priv/ash_dispatch/layouts/sms.text.eex`:
```eex
<%= @subject %>

<%= @inner_content %>

---
Sent from MyApp
```

2. **Create templates** for each event at `priv/ash_dispatch/templates/order/created/sms.text.eex`:
```eex
Order <%= @order_number %> confirmed!
Track at: <%= @order_url %>
```

3. **Add channel** to your event:
```elixir
channels: [
  [transport: :sms, audience: :user, format: :text]
]
```

### Adding Discord with Markdown

1. **Add format config**:
```elixir
config :ash_dispatch,
  format_extensions: %{markdown: "md.eex"}
```

2. **Create layout** at `priv/ash_dispatch/layouts/discord.md.eex`:
```markdown
**<%= @subject %>**

<%= @inner_content %>
```

3. **Create templates** at `priv/ash_dispatch/templates/order/created/discord.md.eex`:
```markdown
🎉 New order **<%= @order_number %>** from <%= @display_name %>!
```

### Custom Layouts Per Channel

Override the layout for specific channels using the `layout` option:

```elixir
channels: [
  # Default layout
  [transport: :email, audience: :user],

  # Custom "urgent" layout for cancelled orders
  [transport: :email, audience: :user, layout: "urgent"],

  # Discord still uses default layout
  [transport: :discord, audience: :admin]
]
```

Layout structure:
```
priv/ash_dispatch/layouts/
├── email.html.heex          # Default email layout
├── email.text.eex           # Default text layout
├── discord.md.eex           # Default Discord layout
└── urgent/
    ├── email.html.heex      # Urgent email (red header)
    └── email.text.eex       # Urgent text
```

The `layout` option is a subdirectory prefix. It tries `layouts/urgent/email.html.heex` first, then falls back to `layouts/email.html.heex` if not found.

**Use cases:**
- Warning/error emails with different header colors
- Admin notifications with different branding
- Promotional emails with special styling

---

## TypeScript SDK Generation

The `mix ash_dispatch.gen` command generates a complete TypeScript SDK based on your DSL definitions:

```bash
# Generate all missing files (templates, event modules, SDK)
mix ash_dispatch.gen

# Preview what would be generated
mix ash_dispatch.gen --dry-run

# CI check - fail if files need generation
mix ash_dispatch.gen --check
```

## What Gets Generated

### TypeScript SDK (8 files)

The generator creates a complete TypeScript SDK for real-time updates:

```
lib/ash-dispatch/
├── index.ts              # Re-exports everything
├── types.ts              # Counter types, defaults, metadata, accessors
├── events.ts             # Event ID types and metadata
├── store.ts              # Zustand store for counters
├── channel.ts            # Phoenix channel utilities
└── hooks/
    ├── use-channel.ts    # WebSocket connection hook
    ├── use-counter.ts    # Single counter access
    └── use-notifications.ts  # Notification management
```

### Counter Types (types.ts)

Type-safe counter definitions for your frontend:

```typescript
// Generated types.ts
export type CounterName =
  | "pending_orders"
  | "cart_items"
  | "unread_notifications"
  | "admin_pending_requests"

export const DEFAULT_COUNTERS: AllCounters = {...}
export const COUNTER_METADATA = {...}

export function isValidCounter(name: string): name is CounterName
export function getCounterAccessors(counters: AllCounters): CounterAccessors
```

### Event Types (events.ts)

Type-safe event definitions for frontend event handling:

```typescript
// Generated events.ts
export type EventId =
  | "orders.created"
  | "orders.completed"
  | "tickets.created"

export const EVENT_METADATA = {
  "orders.created": {
    domain: "orders",
    channels: [{ transport: "email", audience: "user" }],
  },
  // ...
} as const;

export function isValidEventId(id: string): id is EventId
```

### Event Module Stubs

If an event specifies a `module` option but the file doesn't exist, the generator creates a stub:

```elixir
defmodule MyApp.Orders.Events.Created.Event do
  use AshDispatch.Event

  # Override callbacks to customize behavior:
  # @impl true
  # def prepare_template_assigns(context, channel) do
  #   %{order_number: context.data.order.order_number}
  # end
end
```

---

## Configuration

### Required Configuration

```elixir
# config/config.exs

# Path derivation from ash_typescript
config :ash_typescript,
  output_file: "apps/frontend/src/lib/ash_rpc.ts"

# SDK will generate to: apps/frontend/src/lib/ash-dispatch/
```

### Optional Configuration

```elixir
config :ash_dispatch,
  # Override SDK output path
  sdk_output_path: "apps/frontend/src/lib/ash-dispatch",

  # Domains to scan for counters/events
  domains: [MyApp.Orders, MyApp.Tickets, MyApp.Notifications]
```

---

## Usage

### Full Generation

Generate everything at once:

```bash
mix ash_dispatch.gen
```

Output:
```
* creating apps/frontend/src/lib/ash-dispatch/types.ts
* creating apps/frontend/src/lib/ash-dispatch/events.ts
* creating apps/frontend/src/lib/ash-dispatch/store.ts
* creating apps/frontend/src/lib/ash-dispatch/channel.ts
* creating apps/frontend/src/lib/ash-dispatch/index.ts
* creating apps/frontend/src/lib/ash-dispatch/hooks/use-channel.ts
* creating apps/frontend/src/lib/ash-dispatch/hooks/use-counter.ts
* creating apps/frontend/src/lib/ash-dispatch/hooks/use-notifications.ts

Generated 8 file(s)
```

### Incremental Updates

The generator only creates missing files. When you add new counters or events to your DSL,
run `mix ash_dispatch.gen` again to update `types.ts` and `events.ts`:

```bash
# After adding new counters/events
mix ash_dispatch.gen

# Output shows only changed files:
* creating apps/frontend/src/lib/ash-dispatch/types.ts

Generated 1 file(s)
```

---

## Generated SDK Usage

### Frontend Setup

```typescript
// app/providers.tsx
import { useChannel, useCounterStore } from '@/lib/ash-dispatch'

export function Providers({ children }) {
  const userChannel = useUserChannel(userId)

  useChannel({
    channel: userChannel,
    onNotification: (notification) => {
      toast.success(notification.title)
    }
  })

  return <>{children}</>
}
```

### Using Counters

```typescript
import { useCounter } from '@/lib/ash-dispatch'

function CartIcon() {
  const cartItems = useCounter('cart_items')

  return (
    <Badge count={cartItems}>
      <ShoppingCart />
    </Badge>
  )
}
```

### Using the Store Directly

```typescript
import { useCounterStore } from '@/lib/ash-dispatch'

function Dashboard() {
  const counters = useCounterStore(state => state.counters)

  return (
    <div>
      <span>Orders: {counters.pending_orders}</span>
      <span>Tickets: {counters.active_tickets}</span>
    </div>
  )
}
```

### Type Safety

The generated types ensure you only use valid counter names:

```typescript
import type { CounterName } from '@/lib/ash-dispatch'

// ✅ Type-safe
const count = useCounter('cart_items')

// ❌ Type error: "invalid_counter" is not assignable to CounterName
const invalid = useCounter('invalid_counter')
```

---

## Backend Integration

### UserChannel Macro (Recommended)

For minimal backend setup, use the UserChannel macro:

```elixir
defmodule MyAppWeb.UserChannel do
  use AshDispatch.Phoenix.UserChannel,
    endpoint: MyAppWeb.Endpoint
end
```

That's it! 3 lines for a complete real-time channel with:
- Counter broadcasting
- Notification pushing
- Initial state loading

See [Phoenix Integration](phoenix-integration.md#userchannel-macro) for customization options.

---

## Counter Discovery

The generator automatically discovers counters from your resource DSL:

```elixir
# In your resource
defmodule MyApp.Orders.ProductOrder do
  use Ash.Resource, extensions: [AshDispatch.Resource]

  dispatch do
    counters do
      counter :pending_orders,
        trigger_on: [:create, :complete],
        query_filter: [status: :pending],
        audience: :user
    end
  end
end
```

These become TypeScript types and Zustand store keys automatically.

---

## Workflow Integration

### Development Flow

1. Define counters in resource DSL
2. Run `mix ash_dispatch.gen`
3. Import hooks in frontend
4. Use counters with full type safety

### CI/CD

Add to your build pipeline:

```yaml
- name: Generate AshDispatch SDK
  run: mix ash_dispatch.gen --check
```

The `--check` flag exits with error if any files need generation, ensuring your SDK stays in sync with DSL definitions.

---

## Troubleshooting

### "No counters found"

**Cause:** No resources with counter definitions in configured domains.

**Solution:** Ensure domains are configured:
```elixir
# config/config.exs
config :my_app,
  ash_domains: [MyApp.Orders, MyApp.Tickets]

# Or explicitly in ash_dispatch
config :ash_dispatch,
  domains: [MyApp.Orders, MyApp.Tickets]
```

### SDK path incorrect / not generating

**Cause:** `ash_typescript.output_file` not configured.

**Solution:** Set the output path:
```elixir
config :ash_typescript,
  output_file: "apps/frontend/src/lib/ash_rpc.ts"
```

The SDK will generate to `apps/frontend/src/lib/ash-dispatch/`.

### Templates not generating

**Cause:** Events don't have `:email` channels defined, or the templates already exist.

**Solution:** Ensure your event DSL includes email channels:
```elixir
dispatch do
  event :created do
    channels do
      channel :email, :user, subject: "Order created"
    end
  end
end
```

---

## Next Steps

- [Phoenix Integration](phoenix-integration.md) - UserChannel macro setup
- [Counter Broadcasting](counter-broadcasting.md) - Define counters in resources
- [App Integration](app-integration.md) - Complete integration guide