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""" <%= @subject %>
Logo
<%= @inner_content %>

This email was sent by Your Company Name

Unsubscribe · Privacy Policy

""" 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