AshDispatch.UserPreference behaviour (AshDispatch v0.6.4)

View Source

Behaviour for checking user notification preferences.

Allows consuming apps to control which notifications users receive based on their preferences. Users can opt out of specific event categories, transports, or both.

Configuration

Configure your preference checker in config:

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

Implementing the Behaviour

defmodule MyApp.NotificationPreferences do
  @behaviour AshDispatch.UserPreference

  @impl true
  def user_allows?(user_id, event_id, transport, opts) do
    # Query your preference system
    case Ash.get(UserPreference, user_id) do
      {:ok, prefs} ->
        category = opts[:category]

        # Check if user disabled this category
        if category in prefs.disabled_categories do
          false
        # Check if user disabled this transport
        else if transport in prefs.disabled_transports do
          false
        else
          true
        end

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

Usage in Events

Events should specify their category for preference filtering:

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

Preference Granularity

Users can control notifications at three levels:

  1. By Category - Opt out of entire categories (e.g., :marketing, :billing)
  2. By Transport - Opt out of specific transports (e.g., no :email, yes :in_app)
  3. Both - Opt out of category+transport combinations

Default Behavior

If no preference checker is configured, all notifications are sent. This ensures AshDispatch works out-of-the-box without requiring preference setup.

Which deliveries are gated

Only the :user audience is preference-gated by default. Admin/team/system notifications typically shouldn't be user-configurable — an operator must not be able to silence an operational alert by unticking a marketing box.

Apps that fan a marketing-style event out to an audience of their own can opt that audience in:

config :ash_dispatch,
  preference_gated_audiences: [:user, :customers]

See AshDispatch.Config.preference_gated_audiences/0.

Per-recipient evaluation

The gate runs per receipt, i.e. once per recipient, via allows_receipt?/4 — one event fanned out to N recipients produces N independent verdicts. (Before 0.6.4 the transports asked about the context user and applied that single verdict to every recipient.)

Summary

Callbacks

Checks if a user allows a specific notification.

Functions

Checks if user allows notification based on context and channel.

Checks whether the recipient of a receipt allows this delivery.

The pure preference predicate: may this user receive this event on this transport?

Whether deliveries to audience are gated by user preferences.

Callbacks

user_allows?(user_id, event_id, transport, opts)

@callback user_allows?(
  user_id :: any(),
  event_id :: String.t(),
  transport :: atom(),
  opts :: keyword()
) :: boolean()

Checks if a user allows a specific notification.

Parameters

  • user_id - User identifier (can be any type)
  • event_id - Event identifier string (e.g., "orders.created")
  • transport - Transport atom (e.g., :email, :in_app)
  • opts - Options keyword list with:
    • :category - Event category atom (e.g., :billing, :marketing)
    • :audience - Channel audience (e.g., :user, :admin)

Returns

  • true - User allows this notification
  • false - User has opted out

Examples

# User allows order emails
iex> user_allows?(123, "orders.created", :email, category: :transactional)
true

# User opted out of marketing emails
iex> user_allows?(123, "promo.new", :email, category: :marketing)
false

# User allows marketing in-app (only opted out of marketing emails)
iex> user_allows?(123, "promo.new", :in_app, category: :marketing)
true

Functions

allows?(context, channel, event_config \\ [])

Checks if user allows notification based on context and channel.

This is a convenience wrapper around the callback that extracts relevant data from Context and Channel structs.

Prefer allows_receipt?/4 in delivery paths. This function reads the user from the context, which is the event's subject — not necessarily the recipient being delivered to. On a fan-out (one event, N receipts) every recipient gets the context user's verdict. allows_receipt?/4 asks about the receipt's own user, which is what the transports do since 0.6.4.

Returns

  • true - User allows or no user in context or preferences not configured
  • false - User has opted out

allows_receipt?(receipt, context, channel, event_config \\ [])

@spec allows_receipt?(
  map(),
  AshDispatch.Context.t(),
  AshDispatch.Channel.t(),
  keyword()
) :: boolean()

Checks whether the recipient of a receipt allows this delivery.

This is the gate the :email and :in_app transports run before delivering. Unlike allows?/3 it reads receipt.user_id — the actual recipient of this one receipt — so a fan-out to N recipients evaluates N independent preference verdicts instead of applying the context user's verdict to everyone.

Returns true (deliver) when:

  • the channel's audience is not preference-gated (see AshDispatch.Config.preference_gated_audiences/0, default [:user]), or
  • the receipt has no user_id — external recipients (a plain email address, a webhook target) have no preferences to consult, or
  • the configured checker says the user allows it.

Examples

iex> AshDispatch.UserPreference.allows_receipt?(receipt, context, channel, event_config)
true

allows_user?(user_id, event_id, transport, opts \\ [])

@spec allows_user?(any(), String.t(), atom(), keyword()) :: boolean()

The pure preference predicate: may this user receive this event on this transport?

It resolves the configured checker (config :ash_dispatch, user_preference: MyApp.Preferences, defaulting to AshDispatch.UserPreference.Default, which allows everything) and calls its user_allows?/4 callback. It reads nothing from a context, a channel or a receipt, so it can be called from anywhere a user_id is known — a recipient count in an admin screen, a "would this user get it?" check, a digest opt-out — and is guaranteed to agree with the answer the send path will reach.

Returns true when user_id is nil: a recipient without a user account has no preferences, and the library's default is to deliver.

Parameters

  • user_id - the recipient's user id (any type your checker accepts), or nil
  • event_id - event identifier string, e.g. "orders.created"
  • transport - transport atom, e.g. :email, :in_app
  • opts - passed through to the checker, augmented with :event_id:
    • :category - the event's category atom (e.g. :marketing)
    • :audience - the channel audience the check is running for

Examples

# Does this customer still want marketing email?
iex> AshDispatch.UserPreference.allows_user?(user.id, "mailing.sent", :email, category: :marketing)
false

# No user id (external recipient) — nothing to consult, deliver
iex> AshDispatch.UserPreference.allows_user?(nil, "orders.created", :email, [])
true

gated_audience?(audience)

@spec gated_audience?(atom()) :: boolean()

Whether deliveries to audience are gated by user preferences.

Reads AshDispatch.Config.preference_gated_audiences/0, which defaults to [:user] — the only audience any release before 0.6.4 ever gated.