Notification Preferences

Notification Preferences

Per-user granular control over which notification types they receive. Stored in the shared settings table (no encryption , this is a plain boolean toggle, not a secret), surfaced in Settings → Notifications.

Architecture

  • Uses getSetting/setSetting from @/lib/db/settings-helpers.server over the shared settings pgTable (composite (user_id, key) primary key) , see architecture/postgres-port.md for the DB layer, and note the base template's per-user encrypted settings API (getEncryptedSetting/setEncryptedSetting) was dropped during the Postgres port , it had zero callers. This feature never needed encryption to begin with (a boolean toggle, not a secret).
  • Default (no row): marketing emails opt-in
  • Dedicated query key in query-keys.ts for cache management
  • Server fn: getNotificationPreferencesFn (GET), updateNotificationPreferencesFn (POST)
export const updateNotificationPreferencesFn = createServerFn({
  method: 'POST'
})
  .validator(z.object({ marketingEmails: z.boolean() }))
  .handler(async ({ data }) => {
    const user = await requireUser();
    if (!user) return err(ERROR_CODES.UNAUTHORIZED, 'Not authenticated');
    if (notificationPreferencesLimiter.check(user.id))
      return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');

    return withUserTransaction(user.id, async (tx, userId) => {
      await setSetting(
        tx,
        userId,
        MARKETING_EMAILS_KEY,
        data.marketingEmails ? '1' : '0'
      );
      return ok(undefined);
    });
  });

Why the settings table, not the notifications table

This feature and the in-app notification bell (docs/warpkit/features/notifications.md) look similar but store fundamentally different things: the notifications table holds a growing list of discrete, timestamped bell items (append-only, one row per event, read/unread state per row). Notification preferences are a single small set of boolean/string toggles per user that get overwritten in place, never appended to or listed. There's no query pattern here that benefits from a dedicated table (no "list my preference changes," no per-row read state) , a settings key-value row is the right shape, and reusing the same settings helper as other per-user config (e.g. sidebar preferences) avoids introducing a second storage mechanism for what is structurally identical data.

Comparison to Notifications

ConcernNotificationsNotification Preferences
What it storesIn-app bell itemsUser preference toggles
Storagenotifications table (shared Postgres)settings table (shared Postgres)
Mutation sideCreated by notify() helperUpdated by user in settings
Query layernotificationsQueryKeynotificationPreferencesQueryKey

Default behavior

With no settings row present (new user, or a key never written), getNotificationPreferencesFn returns marketingEmails: true — new users are opted in to marketing email by default, matching the signup flow's assumption that a user who just created an account wants product updates until they explicitly opt out.

Tests

Covered in src/features/notification-preferences/server/notification-preferences-crud.test.ts.