User Preferences
View SourceAllow users to control which notifications they receive by implementing preference checking.
Overview
AshDispatch checks user preferences before delivering notifications, giving users control over their notification experience. Users can opt out of specific categories, transports, or combinations of both.
Quick Start
1. Configure Your Preference Checker
# config/config.exs
config :ash_dispatch,
user_preference: MyApp.NotificationPreferences2. Implement the Behaviour
defmodule MyApp.NotificationPreferences do
@behaviour AshDispatch.UserPreference
@impl true
def user_allows?(user_id, event_id, transport, opts) do
category = opts[:category]
# Query your UserPreference resource
case Ash.get(MyApp.Accounts.UserPreference, user_id) do
{:ok, prefs} ->
# Check if user disabled this category
category not in prefs.disabled_categories and
transport not in prefs.disabled_transports
_ ->
true # Allow if no preferences found
end
end
end3. Add Categories to Events
dispatch do
event :promotional_offer,
trigger_on: :create,
channels: [[transport: :email, audience: :user]],
metadata: [
category: :marketing # Users can opt out of this
]
endHow It Works
Preference Check Flow
1. Event triggers
2. Dispatcher creates DeliveryReceipt (status: :pending)
3. Transport checks AshDispatch.UserPreference.allows?()
├─ If false → Mark receipt :skipped (error: "user_opted_out")
└─ If true → Continue with delivery
4. Delivery proceeds normallyKey Point: Receipts are always created for audit purposes, even if skipped.
When Preferences Are Checked
✅ Checked:
- Events with
audience: :user - User-configurable events
❌ Not Checked:
- Events with
audience: :admin - Events with
audience: :team - Events with
audience: :system - System-critical notifications (auth, password reset)
Preference Granularity
Users can control notifications at three levels:
1. By Category
Opt out of entire event categories:
# In your UserPreference resource
attribute :disabled_categories, {:array, :atom}, default: []
# User opts out of marketing
user_preference.disabled_categories = [:marketing, :promotional]
# Events with category: :marketing will be skipped2. By Transport
Opt out of specific delivery methods:
attribute :disabled_transports, {:array, :atom}, default: []
# User opts out of all emails
user_preference.disabled_transports = [:email]
# User still gets :in_app notifications3. Combined
Fine-grained control over category + transport combinations:
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
case Ash.get(UserPreference, user_id) do
{:ok, prefs} ->
# User can disable marketing emails but still get marketing in-app
cond do
{category, transport} in prefs.disabled_combinations ->
false
category in prefs.disabled_categories ->
false
transport in prefs.disabled_transports ->
false
true ->
true
end
_ ->
true
end
endEvent Categories
Define categories that make sense for your application:
Common Categories:
:transactional # Order confirmations, password resets (usually not configurable)
:marketing # Promotional emails, product announcements
:billing # Invoices, payment reminders
:social # Comments, mentions, likes
:product_updates # New features, changelogs
:system # Maintenance notices, service updatesExample Event Categorization:
dispatch do
# Transactional - usually not user-configurable
event :order_confirmed,
metadata: [category: :transactional, user_configurable: false]
# Marketing - user can opt out
event :weekly_newsletter,
metadata: [category: :marketing, user_configurable: true]
# Social - user can control frequency
event :comment_reply,
metadata: [category: :social, user_configurable: true]
endImplementation Patterns
Pattern 1: Simple Category Blocking
defmodule MyApp.Preferences do
@behaviour AshDispatch.UserPreference
@impl true
def user_allows?(user_id, _event_id, _transport, opts) do
category = opts[:category]
# Don't check preferences for critical categories
if category in [:transactional, :security] do
true
else
case get_user_preferences(user_id) do
{:ok, prefs} -> category not in prefs.disabled_categories
_ -> true
end
end
end
defp get_user_preferences(user_id) do
MyApp.Accounts.UserPreference
|> Ash.get(user_id)
end
endPattern 2: Transport-Specific Preferences
@impl true
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# Check transport-specific preferences
case transport do
:email ->
prefs.email_enabled and category not in prefs.email_disabled_categories
:in_app ->
prefs.in_app_enabled and category not in prefs.in_app_disabled_categories
:sms ->
prefs.sms_enabled and category not in prefs.sms_disabled_categories
_ ->
true
end
_ ->
true
end
endPattern 3: Frequency-Based Preferences
@impl true
def user_allows?(user_id, event_id, transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# Check if user wants digest mode
if digest_mode?(prefs, category) do
# Don't send individual notifications, queue for digest
queue_for_digest(user_id, event_id, category)
false # Skip individual delivery
else
# Check normal preferences
category not in prefs.disabled_categories
end
_ ->
true
end
end
defp digest_mode?(prefs, category) do
# User gets daily digest instead of individual emails
prefs.digest_categories
|> Enum.member?(category)
endPattern 4: Time-Based Preferences
@impl true
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# Respect quiet hours
if in_quiet_hours?(prefs) and category != :urgent do
false # Skip non-urgent notifications during quiet hours
else
category not in prefs.disabled_categories
end
_ ->
true
end
end
defp in_quiet_hours?(prefs) do
now = Time.utc_now()
quiet_start = prefs.quiet_hours_start || ~T[22:00:00]
quiet_end = prefs.quiet_hours_end || ~T[08:00:00]
Time.compare(now, quiet_start) != :lt and
Time.compare(now, quiet_end) != :gt
endUserPreference Resource Example
Here's a complete UserPreference resource:
defmodule MyApp.Accounts.UserPreference do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
postgres do
table "user_preferences"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
# User reference (no relationship to stay flexible)
attribute :user_id, :uuid do
allow_nil? false
end
# Category-based preferences
attribute :disabled_categories, {:array, :atom}, default: []
# Transport-based preferences
attribute :email_enabled, :boolean, default: true
attribute :in_app_enabled, :boolean, default: true
attribute :sms_enabled, :boolean, default: false
# Per-transport category preferences
attribute :email_disabled_categories, {:array, :atom}, default: []
attribute :in_app_disabled_categories, {:array, :atom}, default: []
# Digest preferences
attribute :digest_categories, {:array, :atom}, default: []
attribute :digest_frequency, :atom, default: :daily # :daily, :weekly
# Quiet hours
attribute :quiet_hours_start, :time
attribute :quiet_hours_end, :time
timestamps()
end
actions do
defaults [:read, :update]
create :create do
accept [:user_id]
end
update :disable_category do
accept []
argument :category, :atom, allow_nil?: false
change fn changeset, _ ->
category = Ash.Changeset.get_argument(changeset, :category)
current = Ash.Changeset.get_attribute(changeset, :disabled_categories) || []
Ash.Changeset.change_attribute(
changeset,
:disabled_categories,
Enum.uniq([category | current])
)
end
end
update :enable_category do
accept []
argument :category, :atom, allow_nil?: false
change fn changeset, _ ->
category = Ash.Changeset.get_argument(changeset, :category)
current = Ash.Changeset.get_attribute(changeset, :disabled_categories) || []
Ash.Changeset.change_attribute(
changeset,
:disabled_categories,
List.delete(current, category)
)
end
end
end
code_interface do
define :create
define :disable_category, args: [:category]
define :enable_category, args: [:category]
end
identities do
identity :unique_user_id, [:user_id]
end
endTesting Preferences
Test Your Preference Checker
defmodule MyApp.PreferencesTest do
use ExUnit.Case
alias MyApp.NotificationPreferences
setup do
# Create test user with preferences
user = create_user()
{:ok, prefs} = UserPreference.create(%{
user_id: user.id,
disabled_categories: [:marketing],
email_enabled: false
})
%{user: user, prefs: prefs}
end
test "user who disabled marketing category", %{user: user} do
# Should block marketing
refute NotificationPreferences.user_allows?(
user.id,
"promo.new",
:email,
category: :marketing
)
# Should allow transactional
assert NotificationPreferences.user_allows?(
user.id,
"order.created",
:email,
category: :transactional
)
end
test "user who disabled email transport", %{user: user} do
# Should block all emails
refute NotificationPreferences.user_allows?(
user.id,
"any.event",
:email,
category: :billing
)
# Should allow in-app
assert NotificationPreferences.user_allows?(
user.id,
"any.event",
:in_app,
category: :billing
)
end
endIntegration Tests
test "opted-out user gets receipt marked as skipped" do
user = create_opted_out_user()
# Trigger event
{:ok, product} = create_product(%{user_id: user.id})
# Check receipt was created but skipped
receipts = DeliveryReceipt
|> Ash.Query.filter(event_id == "product.created")
|> Ash.read!()
assert length(receipts) == 1
assert hd(receipts).status == :skipped
assert hd(receipts).error_message == "user_opted_out"
# Verify no notification created
notifications = Notification
|> Ash.Query.filter(user_id == ^user.id)
|> Ash.read!()
assert length(notifications) == 0
endUI Integration
Preference Settings Page
# LiveView component
defmodule MyAppWeb.PreferencesLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
user = socket.assigns.current_user
{:ok, prefs} = UserPreference.get_or_create(user.id)
{:ok,
socket
|> assign(:preferences, prefs)
|> assign(:categories, available_categories())}
end
def handle_event("toggle_category", %{"category" => category}, socket) do
category_atom = String.to_existing_atom(category)
user_id = socket.assigns.current_user.id
if category_atom in socket.assigns.preferences.disabled_categories do
UserPreference.enable_category(user_id, category_atom)
else
UserPreference.disable_category(user_id, category_atom)
end
{:ok, prefs} = UserPreference.get(user_id)
{:noreply, assign(socket, :preferences, prefs)}
end
defp available_categories do
[
%{key: :marketing, label: "Marketing & Promotions", description: "Product announcements, special offers"},
%{key: :billing, label: "Billing & Payments", description: "Invoices, payment reminders"},
%{key: :social, label: "Social Activity", description: "Comments, mentions, likes"},
%{key: :product_updates, label: "Product Updates", description: "New features, changelogs"}
]
end
endBest Practices
1. Always Allow Critical Notifications
def user_allows?(user_id, event_id, transport, opts) do
category = opts[:category]
# Never block critical system notifications
if category in [:security, :transactional] do
true
else
check_user_preferences(user_id, category, transport)
end
end2. Default to Opt-In for New Categories
def user_allows?(user_id, _event_id, _transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# If category is unknown, allow (opt-in by default)
if category in known_categories() do
category not in prefs.disabled_categories
else
true
end
_ ->
true
end
end3. Provide Clear Category Descriptions
Make it easy for users to understand what they're opting out of:
@category_descriptions %{
marketing: "Promotional emails, product announcements, special offers",
billing: "Invoices, payment reminders, subscription updates",
social: "Comments, mentions, likes, and other social interactions",
system: "Important system announcements and maintenance notices"
}4. Log Preference Decisions
def user_allows?(user_id, event_id, transport, opts) do
result = check_preferences(user_id, event_id, transport, opts)
unless result do
Logger.info("""
User #{user_id} opted out of notification
Event: #{event_id}
Transport: #{transport}
Category: #{opts[:category]}
""")
end
result
endPerformance Considerations
Caching
User preferences are read-heavy. Consider caching:
defmodule MyApp.PreferenceCache do
use Nebulex.Cache,
otp_app: :my_app,
adapter: Nebulex.Adapters.Local
end
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
cache_key = {:user_prefs, user_id}
prefs = PreferenceCache.get(cache_key, fn ->
case get_user_preferences(user_id) do
{:ok, prefs} -> prefs
_ -> nil
end
end)
check_preferences(prefs, category, transport)
endBatch Checking
For multi-recipient events:
def batch_allows?(user_ids, event_id, transport, opts) when is_list(user_ids) do
# Fetch all preferences in one query
prefs = UserPreference
|> Ash.Query.filter(user_id in ^user_ids)
|> Ash.read!()
|> Map.new(&{&1.user_id, &1})
# Return map of user_id => boolean
Map.new(user_ids, fn user_id ->
pref = Map.get(prefs, user_id)
{user_id, check_single_preference(pref, event_id, transport, opts)}
end)
endTroubleshooting
Preferences Not Being Respected
Check configuration:
config :ash_dispatch, user_preference: MyApp.PreferencesVerify callback implementation:
@behaviour AshDispatch.UserPreference @impl true def user_allows?(...) do # Implementation here endCheck event category:
metadata: [category: :marketing]
All Notifications Being Skipped
Check that your user_allows?/4 isn't accidentally returning false for all cases:
# Add logging
def user_allows?(user_id, event_id, transport, opts) do
result = check_preferences(...)
Logger.debug("""
Preference check:
User: #{user_id}
Event: #{event_id}
Transport: #{transport}
Result: #{result}
""")
result
endSee Also
- Recipient Resolution - Finding notification recipients
- Getting Started - Basic setup
- DSL Reference - Event configuration