Packages

Event-driven notification system for Ash Framework with multiple transport types

Current section

Files

Jump to
ash_dispatch lib mix tasks ash_dispatch.install.ex
Raw

lib/mix/tasks/ash_dispatch.install.ex

defmodule Mix.Tasks.AshDispatch.Install.Docs do
@moduledoc false
def short_doc do
"Installs AshDispatch into a project"
end
def example do
"mix igniter.install ash_dispatch"
end
def long_doc do
"""
#{short_doc()}
This installer will:
- Add AshDispatch configuration to config files
- Create Notification and DeliveryReceipt resources
- Create Notifications and Deliveries domains
- Add domains to your :ash_domains configuration
- Create a RecipientResolver module for declarative audience resolution
- Set up email layout templates
- Optionally set up Phoenix channel for real-time updates
## Example
```bash
#{example()}
```
## Options
* `--no-phoenix` - Skip Phoenix channel setup
* `--no-email` - Skip email backend configuration
* `--no-typescript` - Skip TypeScript SDK generation (runs automatically if any domain uses AshTypescript.Rpc)
"""
end
end
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.AshDispatch.Install do
@shortdoc "#{__MODULE__.Docs.short_doc()}"
@moduledoc __MODULE__.Docs.long_doc()
use Igniter.Mix.Task
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :ash,
installs: [{:ash, "~> 3.0"}, {:oban, "~> 2.0"}],
example: __MODULE__.Docs.example(),
schema: [
phoenix: :boolean,
email: :boolean,
typescript: :boolean
],
defaults: [
phoenix: true,
email: true,
typescript: true
],
aliases: []
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
opts = igniter.args.options
otp_app = Igniter.Project.Application.app_name(igniter)
app_module = Igniter.Project.Module.module_name(igniter, "")
# Detect user resource (common patterns)
user_resource = detect_user_resource(igniter, app_module)
igniter
|> Igniter.Project.Formatter.import_dep(:ash_dispatch)
|> configure_ash_dispatch(otp_app)
|> configure_test(otp_app)
|> create_notification_resource(otp_app, app_module, user_resource)
|> create_delivery_receipt_resource(otp_app, app_module)
|> create_notifications_domain(app_module)
|> create_deliveries_domain(app_module)
|> add_domains_to_config(otp_app, app_module)
|> create_recipient_resolver(app_module, user_resource)
|> setup_email_layouts()
|> maybe_setup_phoenix(opts[:phoenix], otp_app, app_module)
|> maybe_configure_email(opts[:email], otp_app, app_module)
|> maybe_run_typescript(opts[:typescript])
|> add_final_notice(otp_app, app_module, user_resource)
end
# ============================================
# Configuration
# ============================================
defp configure_ash_dispatch(igniter, otp_app) do
igniter
|> Igniter.Project.Config.configure_new(
"config.exs",
:ash_dispatch,
[:otp_app],
otp_app
)
end
defp configure_test(igniter, otp_app) do
# Configure Oban for inline testing if Oban config exists
igniter
|> Igniter.Project.Config.configure_new(
"test.exs",
otp_app,
[Oban, :testing],
:inline
)
end
# ============================================
# Resource Generation
# ============================================
defp create_notification_resource(igniter, otp_app, app_module, user_resource) do
module_name = Module.concat([app_module, Notifications, Notification])
user_resource_str =
if user_resource do
"user_resource: #{inspect(user_resource)},"
else
"# user_resource: #{inspect(app_module)}.Accounts.User, # Uncomment and update"
end
content = """
defmodule #{inspect(module_name)} do
@moduledoc \"\"\"
In-app notification resource for storing user notifications.
Generated by `mix ash_dispatch.install`.
\"\"\"
use AshDispatch.Notification,
domain: #{inspect(Module.concat(app_module, Notifications))},
#{user_resource_str}
otp_app: :#{otp_app}
end
"""
Igniter.Project.Module.create_module(igniter, module_name, content)
end
defp create_delivery_receipt_resource(igniter, otp_app, app_module) do
module_name = Module.concat([app_module, Deliveries, DeliveryReceipt])
content = """
defmodule #{inspect(module_name)} do
@moduledoc \"\"\"
Delivery receipt resource for tracking all notification deliveries.
Generated by `mix ash_dispatch.install`.
\"\"\"
use AshDispatch.DeliveryReceipt,
domain: #{inspect(Module.concat(app_module, Deliveries))},
otp_app: :#{otp_app}
end
"""
Igniter.Project.Module.create_module(igniter, module_name, content)
end
# ============================================
# Domain Generation
# ============================================
defp create_notifications_domain(igniter, app_module) do
module_name = Module.concat(app_module, Notifications)
resource_name = Module.concat([app_module, Notifications, Notification])
content = """
defmodule #{inspect(module_name)} do
@moduledoc \"\"\"
Domain for in-app notifications.
Generated by `mix ash_dispatch.install`.
\"\"\"
use Ash.Domain
resources do
resource #{inspect(resource_name)}
end
end
"""
Igniter.Project.Module.create_module(igniter, module_name, content)
end
defp create_deliveries_domain(igniter, app_module) do
module_name = Module.concat(app_module, Deliveries)
resource_name = Module.concat([app_module, Deliveries, DeliveryReceipt])
content = """
defmodule #{inspect(module_name)} do
@moduledoc \"\"\"
Domain for delivery receipts and tracking.
Generated by `mix ash_dispatch.install`.
\"\"\"
use Ash.Domain
resources do
resource #{inspect(resource_name)}
end
end
"""
Igniter.Project.Module.create_module(igniter, module_name, content)
end
defp add_domains_to_config(igniter, otp_app, app_module) do
notifications_domain = Module.concat(app_module, Notifications)
deliveries_domain = Module.concat(app_module, Deliveries)
igniter
|> Igniter.Project.Config.configure(
"config.exs",
otp_app,
[:ash_domains],
[notifications_domain, deliveries_domain],
updater: fn zipper ->
zipper
|> Igniter.Code.List.append_new_to_list(notifications_domain)
|> case do
{:ok, zipper} -> Igniter.Code.List.append_new_to_list(zipper, deliveries_domain)
other -> other
end
end
)
end
# ============================================
# Recipient Resolver
# ============================================
defp create_recipient_resolver(igniter, app_module, user_resource) do
resolver_module = Module.concat(app_module, RecipientResolver)
user_resource_str = inspect(user_resource || Module.concat(app_module, Accounts.User))
content = """
@moduledoc \"\"\"
Recipient resolver for notification audiences.
Define how to resolve recipients for each audience type using
the AshDispatch.RecipientResolver DSL.
## Audience Strategies
| Strategy | Example | Description |
|----------|---------|-------------|
| `from_context` | `from_context: :user` | Extract from context.data |
| `query` | `query: [role: :admin]` | Query user_resource with Ash filter |
| `path` | `path: [:team, :users]` | Follow relationship path on resource |
| `combine` | `combine: [:owner, :team]` | Union of other audiences |
| `resolve` | `resolve: :resolve_owner` | Custom resolver function |
## Usage
Reference audiences in your dispatch DSL:
dispatch do
event :order_created do
channel :in_app, audience: :owner
channel :email, audience: :team
end
end
\"\"\"
use AshDispatch.RecipientResolver,
user_resource: #{user_resource_str}
audiences do
# Context-based - extract user from event context
audience :user, from_context: :user
# Fallback chain - tries :user first, then :assignee
audience :assignee, from_context: [:user, :assignee]
# Query-based - find users matching filter
# audience :admins, query: [role: :admin, is_active: true]
# Relationship path - follow relationships on the resource
# audience :team, path: [:team_members, :user]
# Composite - union of other audiences (deduped by id)
# audience :stakeholders, combine: [:owner, :team]
# Custom resolver - for complex business logic
# audience :owner, resolve: :resolve_owner
# Custom resolver returning raw maps (not user structs)
# audience :lead_contact, resolve: :resolve_lead_contact, raw: true
end
@impl true
def to_recipient(%#{user_resource_str}{} = user) do
%{
id: user.id,
email: to_string(user.email),
display_name: extract_display_name(user)
}
end
defp extract_display_name(user) do
cond do
Map.has_key?(user, :full_name) && user.full_name ->
to_string(user.full_name)
Map.has_key?(user, :first_name) && user.first_name ->
to_string(user.first_name)
true ->
to_string(user.email)
end
end
# Example custom resolver:
#
# def resolve_owner(resource, context) do
# case Map.get(context.data, :owner) do
# nil -> []
# owner -> [owner]
# end
# end
"""
igniter
|> Igniter.Project.Module.create_module(resolver_module, content)
|> Igniter.Project.Config.configure_new(
"config.exs",
:ash_dispatch,
[:recipient_resolver],
resolver_module
)
end
# ============================================
# Email Layouts
# ============================================
defp setup_email_layouts(igniter) do
html_layout = email_html_layout_content()
text_layout = email_text_layout_content()
igniter
|> Igniter.create_new_file(
"priv/ash_dispatch/layouts/email.html.heex",
html_layout,
on_exists: :skip
)
|> Igniter.create_new_file(
"priv/ash_dispatch/layouts/email.text.eex",
text_layout,
on_exists: :skip
)
end
defp email_html_layout_content do
~S"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title><%= @subject %></title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
background-color: #f4f4f5;
}
/* Link styles */
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<!-- Wrapper table -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Main content container -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="padding: 32px 40px 24px 40px; border-bottom: 1px solid #e5e7eb;">
<!-- Replace with your logo -->
<img src="https://via.placeholder.com/150x40?text=Your+Logo" alt="Logo" style="max-height: 40px; width: auto;">
</td>
</tr>
<!-- Body content -->
<tr>
<td style="padding: 32px 40px;">
<%= @inner_content %>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 40px 32px 40px; border-top: 1px solid #e5e7eb; background-color: #f9fafb; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 8px 0; font-size: 12px; color: #6b7280; text-align: center;">
This email was sent by Your Company Name
</p>
<p style="margin: 0; font-size: 12px; color: #6b7280; text-align: center;">
<a href="#" style="color: #6b7280; text-decoration: underline;">Unsubscribe</a> &middot;
<a href="#" style="color: #6b7280; text-decoration: underline;">Privacy Policy</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
end
defp email_text_layout_content do
"""
<%= @inner_content %>
---
This email was sent by Your Company Name
To unsubscribe, visit: [unsubscribe link]
"""
end
# ============================================
# Phoenix Integration
# ============================================
defp maybe_setup_phoenix(igniter, false, _otp_app, _app_module), do: igniter
defp maybe_setup_phoenix(igniter, true, otp_app, app_module) do
web_module = detect_web_module(igniter, app_module)
if web_module do
igniter
|> create_user_channel(web_module)
|> create_inbox_controller(web_module, otp_app)
|> add_inbox_routes(web_module)
|> configure_counter_broadcast(otp_app, web_module)
else
Igniter.add_notice(
igniter,
"""
⚠️ Phoenix web module not detected. Skipping channel setup.
You can manually create a UserChannel for real-time notifications.
See: https://ash-dispatch-docs.pages.dev/phoenix-integration
"""
)
end
end
defp create_user_channel(igniter, web_module) do
channel_module = Module.concat(web_module, UserChannel)
web_module_str = inspect(web_module) |> String.replace_prefix("Elixir.", "")
content = """
defmodule #{inspect(channel_module)} do
@moduledoc \"\"\"
Phoenix channel for real-time user notifications and counter updates.
Generated by `mix ash_dispatch.install`.
\"\"\"
use Phoenix.Channel
alias AshDispatch.Helpers.ChannelState
@doc \"\"\"
Join the user's personal channel.
\"\"\"
def join("user:" <> user_id, _params, socket) do
if authorized?(socket, user_id) do
send(self(), :after_join)
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
@doc \"\"\"
Send initial state after joining.
\"\"\"
def handle_info(:after_join, socket) do
user_id = socket.assigns.user_id
# Load all counters and recent notifications
initial_state = ChannelState.build(user_id)
push(socket, "initial_state", initial_state)
{:noreply, socket}
end
@doc \"\"\"
Broadcast a counter update to the user's channel.
Called by AshDispatch when counters change.
\"\"\"
def broadcast_counter(user_id, counter_name, value) do
#{web_module_str}.Endpoint.broadcast(
"user:\#{user_id}",
"counter_update",
%{counter: counter_name, value: value}
)
end
defp authorized?(socket, user_id) do
# Verify the user can access this channel
socket.assigns[:user_id] == user_id
end
end
"""
Igniter.Project.Module.create_module(igniter, channel_module, content)
end
defp create_inbox_controller(igniter, web_module, _otp_app) do
controller_module = Module.concat(web_module, InboxApiController)
web_module_str = inspect(web_module) |> String.replace_prefix("Elixir.", "")
content = """
defmodule #{inspect(controller_module)} do
@moduledoc \"\"\"
API controller for inbox-related endpoints.
Generated by `mix ash_dispatch.install`.
\"\"\"
use #{web_module_str}, :controller
@doc \"\"\"
Generate a socket token for the current user.
Used by the frontend to authenticate Phoenix channel connections.
\"\"\"
def socket_token(conn, _params) do
user = conn.assigns[:current_user]
if user do
token = Phoenix.Token.sign(#{web_module_str}.Endpoint, "user socket", user.id)
json(conn, %{success: true, data: %{token: token}})
else
conn
|> put_status(:unauthorized)
|> json(%{success: false, error: "Not authenticated"})
end
end
end
"""
Igniter.Project.Module.create_module(igniter, controller_module, content)
end
defp add_inbox_routes(igniter, web_module) do
route_code = """
# AshDispatch inbox API
get "/api/inbox/socket-token", InboxApiController, :socket_token
"""
Igniter.Libs.Phoenix.append_to_scope(igniter, "/", route_code,
arg2: web_module,
placement: :after
)
rescue
_ ->
Igniter.add_notice(
igniter,
"""
⚠️ Could not automatically add inbox routes.
Please add manually to your router:
get "/api/inbox/socket-token", InboxApiController, :socket_token
"""
)
end
defp configure_counter_broadcast(igniter, _otp_app, web_module) do
channel_module = Module.concat(web_module, UserChannel)
Igniter.Project.Config.configure_new(
igniter,
"config.exs",
:ash_dispatch,
[:counter_broadcast_fn],
{channel_module, :broadcast_counter}
)
end
# ============================================
# Email Configuration
# ============================================
defp maybe_configure_email(igniter, false, _otp_app, _app_module), do: igniter
defp maybe_configure_email(igniter, true, _otp_app, app_module) do
mailer_module = Module.concat(app_module, Mailer)
# Check if mailer exists
{mailer_exists?, igniter} = Igniter.Project.Module.module_exists(igniter, mailer_module)
if mailer_exists? do
igniter
|> Igniter.Project.Config.configure_new(
"config.exs",
:ash_dispatch,
[:email_backend],
AshDispatch.EmailBackend.Swoosh
)
|> Igniter.Project.Config.configure_new(
"config.exs",
:ash_dispatch,
[:swoosh_mailer],
mailer_module
)
else
Igniter.add_notice(
igniter,
"""
⚠️ Mailer module #{inspect(mailer_module)} not found.
To enable email notifications, configure Swoosh:
config :ash_dispatch,
email_backend: AshDispatch.EmailBackend.Swoosh,
swoosh_mailer: #{inspect(mailer_module)}
"""
)
end
end
# ============================================
# TypeScript SDK
# ============================================
defp maybe_run_typescript(igniter, false), do: igniter
defp maybe_run_typescript(igniter, true) do
# Check if ash_typescript is actually being used in the project
# by checking if any domain has the AshTypescript.Rpc extension
if ash_typescript_in_use?() do
Igniter.add_task(igniter, "ash_dispatch.gen")
else
# ash_typescript not in use - just skip silently (it's optional)
# User can run `mix ash_dispatch.gen` later if they set it up
igniter
end
end
defp ash_typescript_in_use? do
# Check if AshTypescript.Rpc module exists
if Code.ensure_loaded?(AshTypescript.Rpc) do
# Check if any domain uses the AshTypescript.Rpc extension
otp_app = Mix.Project.config()[:app]
domains = Application.get_env(otp_app, :ash_domains, [])
Enum.any?(domains, fn domain ->
Code.ensure_loaded?(domain) and
function_exported?(domain, :spark_dsl_config, 0) and
has_ash_typescript_extension?(domain)
end)
else
false
end
end
defp has_ash_typescript_extension?(domain) do
try do
extensions = Spark.extensions(domain)
AshTypescript.Rpc in extensions
rescue
_ -> false
end
end
# ============================================
# Helpers
# ============================================
defp detect_user_resource(igniter, app_module) do
# Common user resource locations
candidates = [
Module.concat([app_module, Accounts, User]),
Module.concat([app_module, Users, User]),
Module.concat([app_module, User])
]
Enum.find(candidates, fn module ->
{exists?, _} = Igniter.Project.Module.module_exists(igniter, module)
exists?
end)
end
defp detect_web_module(igniter, app_module) do
# Common web module patterns
candidates = [
Module.concat(app_module, Web),
Module.concat([app_module |> to_string() |> Kernel.<>("Web") |> String.to_atom()])
]
Enum.find(candidates, fn module ->
{exists?, _} = Igniter.Project.Module.module_exists(igniter, module)
exists?
end)
end
# ============================================
# Final Notice
# ============================================
defp add_final_notice(igniter, _otp_app, app_module, user_resource) do
user_notice =
if user_resource do
""
else
"""
⚠️ User resource not detected. Update your Notification resource:
use AshDispatch.Notification,
user_resource: YourApp.Accounts.User,
...
"""
end
notice = """
🎉 AshDispatch has been installed!
Created:
- #{inspect(Module.concat([app_module, Notifications, Notification]))}
- #{inspect(Module.concat(app_module, Notifications))} (domain)
- #{inspect(Module.concat([app_module, Deliveries, DeliveryReceipt]))}
- #{inspect(Module.concat(app_module, Deliveries))} (domain)
- priv/ash_dispatch/layouts/email.html.heex
- priv/ash_dispatch/layouts/email.text.eex
#{user_notice}
Next Steps:
1. Run migrations:
mix ash.codegen add_ash_dispatch
mix ash.migrate
2. Add events to your resources:
defmodule #{inspect(app_module)}.Orders.Order do
use Ash.Resource,
extensions: [AshDispatch.Resource]
dispatch do
event :created,
trigger_on: :create,
channels: [
[transport: :in_app, audience: :user]
],
content: [
notification_title: "Order Created",
notification_message: "Your order #\{\{id\}\} was created"
]
end
end
3. Generate email templates (if using email channel):
mix ash.codegen
4. (Optional) Generate TypeScript SDK:
mix ash_dispatch.gen
📚 Documentation: https://ash-dispatch-docs.pages.dev
"""
Igniter.add_notice(igniter, notice)
end
end
else
defmodule Mix.Tasks.AshDispatch.Install do
@shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use"
@moduledoc __MODULE__.Docs.long_doc()
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_dispatch.install' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end