Preferences

User Preferences

Facteur includes a built-in preference system that lets users control how they receive notifications. Users can toggle channels on or off at different levels of granularity: globally, per category, or per notification.

Channel resolution reads database-backed preferences for recipients with an id, unless you use explicit via() targets or a critical notification. Configure a top-level databaseAdapter and call facteur.discoverer.discoverNotifications() before sending or serving preference reads. See Configuration.

How it works

Preferences are stored in your database as simple rows, each mapping a user to a set of channel toggles. A single preference entry looks like this:

user_idtenant_idnotification_namechannels
user-123nullnull{"email": false, "sms": true}
user-123nullinvoice-paid{"email": true}
  • When notification_name is NULL, the preference applies globally to all notifications.
  • When notification_name is set, it applies only to that specific notification.

Create the notification_preferences table alongside the notifications table. This example uses PostgreSQL syntax; adapt it for your database:

CREATE TABLE notification_preferences (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
tenant_id TEXT,
notification_name TEXT,
channels JSON NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP,
UNIQUE (user_id, tenant_id, notification_name)
);
CREATE INDEX idx_preferences_user_id ON notification_preferences (user_id);
CREATE INDEX idx_preferences_tenant_id ON notification_preferences (tenant_id);

notification_name should store the notification's options.identifier (or class name when omitted), not its display label. SQL databases generally allow duplicate nullable values in a unique constraint; if you need database-enforced uniqueness for global scopes, add dialect-appropriate null-aware indexes or constraints.

Resolution priority

When Facteur resolves which channels to use for a notification, preferences are checked in the following order (most specific wins):

  1. Notification-specific tenant preference — "Disable email for Invoice Paid in org-123"
  2. Tenant global preference — "Disable email for all notifications in org-123"
  3. Notification-specific global preference — "Disable email for Invoice Paid everywhere"
  4. Global preference — "Disable email for everything"
  5. Category preference — Defaults from your Facteur configuration
  6. deliverBy — The notification's own channel configuration

The resolver takes the first defined channel value from these sources, but never re-enables a channel whose deliverBy result is false.

Current limitations: preference reads prefill every discovered notification with preferences.global.channels (all configured channels default to true). These generated per-notification values can mask global user settings and category defaults lower in the chain, even when no notification-specific row exists. Do not rely on a global/category opt-out alone; verify actual delivery behavior for your configuration. The preferences.enabled option is currently not consulted by the resolver. AdonisJS's provider does not forward configured preference defaults at all.

Critical notifications (critical: true) and explicit .via() bypass preferences. Critical notifications still respect deliverBy conditions and require targets.

Default preferences

You can configure default channel preferences in your Facteur configuration. These act as the baseline before any user preferences are applied.

// Pass this as the preferences option in your core configuration.
// Here email and sms are application-defined channel keys.
const preferences = {
global: {
channels: { email: true, sms: true },
},
categories: {
marketing: { channels: { email: true, sms: false } },
billing: true, // All channels enabled
},
}

Category preferences use the notification's category field:

export default class InvoicePaidNotification extends Notification<User, InvoicePaidParams> {
static options = {
name: 'Invoice Paid',
identifier: 'invoice-paid',
category: 'billing',
deliverBy: { email: true, sms: true },
}
}

Updating preferences via the API

Facteur's built-in API exposes a POST endpoint to update preferences. The request body accepts a preferences object (a flat map of channel names to booleans) and an optional scope.

Global scope

Omit notificationName and category to update global preferences for all notifications:

POST /notifications/notifiable/:notifiableId/preferences
{
"preferences": { "email": false, "sms": true }
}

Per-notification scope

Pass notificationName to update preferences for a specific notification:

POST /notifications/notifiable/:notifiableId/preferences
{
"preferences": { "email": false },
"notificationName": "invoice-paid"
}

Per-category scope

Pass category to update preferences for all notifications that belong to a category:

POST /notifications/notifiable/:notifiableId/preferences
{
"preferences": { "sms": false },
"category": "billing"
}

This resolves all currently discovered notifications with category: 'billing' and updates each one individually. It does not save a category rule for notifications added later. Updates replace the stored channel map for the selected scope; include all toggles you want to preserve.

notificationName and category are mutually exclusive — providing both returns a 400 error.

Tenant-scoped updates

Add tenantId to scope the update to a specific tenant:

POST /notifications/notifiable/:notifiableId/preferences
{
"preferences": { "email": false },
"notificationName": "invoice-paid",
"tenantId": "org-123"
}

See Multi-tenancy for more details on tenant-scoped preferences.

Reading preferences

The GET endpoint returns the full preference structure for a user:

GET /notifications/notifiable/:notifiableId/preferences?tenantId=org-123

The response includes all registered notifications with their current channel preferences, organized by scope:

{
global: {
global: { channels: { email: true, sms: true } },
notifications: [
{
notification: { name: 'Invoice Paid', identifier: 'invoice-paid' },
channels: { email: true, sms: false }
},
// ... all other registered notifications
]
},
tenants: {
'org-123': {
global: { channels: { email: true, sms: true } },
notifications: [...]
}
}
}

Frontend integration

Using @facteurjs/client

// Fetch preferences
const preferences = await facteur.preferences.list({ tenantId: 'org-123' })
// Update globally (disable email for everything)
await facteur.preferences.update({
preferences: { email: false },
})
// Update for a specific notification
await facteur.preferences.update({
preferences: { email: false },
notificationName: 'invoice-paid',
})
// Update for a category
await facteur.preferences.update({
preferences: { sms: false },
category: 'marketing',
})

Using @facteurjs/react

import { usePreferences, useUpdatePreferences } from '@facteurjs/react'
function NotificationSettings() {
const { data: preferences, isLoading } = usePreferences()
const { mutate: updatePreferences } = useUpdatePreferences()
function handleToggleEmail(enabled: boolean) {
updatePreferences({
preferences: { email: enabled },
notificationName: 'invoice-paid',
})
}
// Build your settings UI using the preferences data
}

See the @facteurjs/client and @facteurjs/react pages for the full API reference.