Preferences
Per-type, per-channel notification preferences with deterministic resolution and suppression.
Preferences let users turn a notification type—or a single channel—off.
Subsequent notify() calls suppress disabled channels: the push is not
sent and/or the item does not enter the in-app feed, while an auditable
suppressed delivery row is still recorded.
Preferences are rows in your Postgres (bp_preference), keyed by
(user_id, type, channel). Add the table to your schema:
export {
bpDevice,
bpNotification,
bpDelivery,
bpPreference,
} from "@better-push/core/adapters/drizzle/schema";Resolution rule
For a (userId, type, channel), enabled is decided by the first match in
this precedence order:
| # | Match |
|---|---|
| 1 | stored row (type, channel) exact |
| 2 | stored row (type, "*") |
| 3 | stored row ("*", channel) |
| 4 | stored row ("*", "*") |
| 5 | the definition's defaultEnabled (if the type is registered) |
| 6 | true (global default) |
Resolution is per channel, computed from a single listPreferences(userId) read
plus in-memory precedence - never a query per channel. "*" is the wildcard for
"all types" or "all channels".
Endpoints
Added to the existing router; both require a session.
GET {basePath}/preferences
Returns one entry per registered notification type, with resolved per-channel state:
{
"preferences": [
{ "type": "orderShipped", "label": "Order shipped", "group": "Orders",
"channels": { "push": true, "inApp": true } },
{ "type": "newComment", "label": "New comment", "group": "Social",
"channels": { "push": false, "inApp": true } }
]
}The channels listed per type are the definition's channels. If notifications
is empty, preferences is [].
PUT {basePath}/preferences
Bulk upsert on (user_id, type, channel):
{ "preferences": [ { "type": "newComment", "channel": "push", "enabled": false } ] }Each type must be "*" or a registered type key; each channel must be
"push", "inApp", or "*". An unknown type or channel returns 400. The
response is the same shape as GET /preferences (the post-update resolved view),
so the client can re-render without a second fetch.
usePreferences
"use client";
import { usePreferences } from "@better-push/core/react";
export function Settings() {
const { preferences, dirty, isSaving, setPreference, save } = usePreferences();
return (
<div>
{preferences.map((entry) =>
Object.entries(entry.channels).map(([channel, on]) => (
<label key={entry.type + channel}>
<input
type="checkbox"
checked={on}
onChange={(e) => setPreference(entry.type, channel as "push" | "inApp", e.target.checked)}
/>
{entry.label} · {channel}
</label>
)),
)}
<button disabled={!dirty || isSaving} onClick={() => save()}>
{isSaving ? "Saving…" : "Save"}
</button>
</div>
);
}setPreference edits local state (and sets dirty); save PUTs only the changed
entries and adopts the server's resolved view from the response. reset discards
edits.
NotificationPreferences component
This styled copy-in version is interactive and uses fixture state, so changing a switch here does not save anything to a server:
The headless component renders usePreferences as grouped rows of per-channel
toggles with a Save button:
"use client";
import { NotificationPreferences } from "@better-push/core/react";
export default function Page() {
return <NotificationPreferences />;
}Props: renderRow and className for styling, or pass a shared preferences
hook result.
In React Native the same usePreferences() hook is available from
@better-push/core/native, with the same return value; you
render the toggles with RN primitives.
Suppression
When you call notify(), better-push resolves each requested channel:
- inApp enabled → an
inAppdelivereddelivery (the item enters the feed). - inApp disabled → an
inAppsuppresseddelivery (excluded from the feed). - push enabled → active devices are loaded and sent through their provider.
- push disabled → one
pushsuppressedmarker, and no device sends.
The bp_notification row is always written (it is the event record).
NotifyResult.deliveries includes the suppressed entries so callers can see what
happened.
Preference scope
Preferences are per-type, per-channel on/off switches. Quiet-hour windows and time-based suppression are not supported.
Hook result
Prop
Type