Core Resources
View as Markdown

Notification Preference

Per-(user, event-type, channel) routing decisions, optionally scoped to one Droplet or Space. Three-tier lookup — per-subject override beats global override beats compiled-in default.

A Notification Preference is a single row that overrides the default routing for one event type on one channel. The full routing decision for a single (user, event, channel) lookup is resolved through three tiers in order:

  1. Per-subject row (subjectType + subjectId set) — most specific. "Mute FIELD_CHANGED on the noisy dashboard, on every channel."
  2. Global override (subjectType and subjectId both null) — "Always send FIELD_CHANGED to email."
  3. Compiled-in default (no row in the database) — the matrix below, hard-coded in lib/change-events/defaults.ts. Defaults are opinionated and live; a brand-new user with no rows behaves identically to a user with the seeded defaults.

Default routing matrix (returned on every list response so the UI can render without re-deriving):

Event typeIN_APPEMAILSLACK
FIELD_CHANGED✓✗✗
VERSION_PUBLISHED✓✗✗
SHARED_WITH_YOU✓✓✗
ACCESS_GRANTED✓✓✓
ACCESS_REQUESTED✓✓✓
COMMENT✓✓✗
REPLY✓✓✗
MENTION✓✓✗
STATE_CHANGED✓✗✗
OWNERSHIP_TRANSFERRED✓✓✗
MIGRATION_AUDIENCE_CHANGE✓✓✗
DROPLET_ARCHIVED_BY_SPACE✓✓✗
DROPLET_PDF_DOWNLOADED✓✓✗

FIELD_CHANGED defaults to in-app only to keep email digest volume manageable (SC-07 — digest growth ≤ 20% week-over-week).

The "Access" bucket. ACCESS_REQUESTED (someone asked for access to a Droplet or Space you own) and ACCESS_GRANTED (you were given access) share one preference row — the settings page shows a single Access row. Reads and writes for ACCESS_REQUESTED resolve to the ACCESS_GRANTED key, so set preferences on ACCESS_GRANTED. Slack defaults on for this bucket because access requests arrive as a Slack DM with Add / Dismiss buttons; it is the only event type with a Slack delivery today.

Mute-a-subject is a sugar verb. Calling muteSubject writes three rows in one transaction — one per channel (IN_APP, EMAIL, SLACK), all with enabled=false, scoped to the given subject. unmuteSubject deletes those same three rows. The matrix UI surfaces this as a single toggle per subject; equivalent direct calls via update are possible but require three round trips. Note the Watch model has a separate MUTED source — the two paths are independent: Watch.MUTED suppresses fan-out for that user; NotificationPreference rows with enabled=false suppress delivery per channel. The UI labels both as "mute" for clarity.

The Notification Preference object

Every Notification Preference carries these attributes.

Attributes

eventType
enum
FIELD_CHANGEDVERSION_PUBLISHEDSHARED_WITH_YOUACCESS_GRANTEDACCESS_REQUESTEDCOMMENTREPLYMENTIONMIGRATION_AUDIENCE_CHANGEDROPLET_ARCHIVED_BY_SPACEDROPLET_PDF_DOWNLOADEDSTATE_CHANGEDOWNERSHIP_TRANSFERRED
Which event this preference governs. ACCESS_REQUESTED shares ACCESS_GRANTED's row (the "Access" bucket). Pre-042 values (COMMENT, REPLY, MENTION, MIGRATION_AUDIENCE_CHANGE, DROPLET_ARCHIVED_BY_SPACE, DROPLET_PDF_DOWNLOADED) keep their pre-existing behavior; 042 adds the first four (FIELD_CHANGED, VERSION_PUBLISHED, SHARED_WITH_YOU, ACCESS_GRANTED).
channel
enum
IN_APPEMAILSLACK
The delivery channel this preference governs. IN_APP — the bell icon + agent panel. EMAIL — the daily digest. SLACK — channel-bound notifications (off by default for every event type today; reserved for the planned Space-Slack binding).
enabled
boolean
true — route this (eventType, channel) to this subject (or globally). false — suppress delivery. Setting enabled=false for a (eventType, channel) is the unit of "mute this combination"; muteSubject is a convenience that writes one row per channel for a given subject.
subjectType
enum· nullable
APPSPACE
APP / SPACE for a per-subject override; null for a global preference. The lookup order checks per-subject first (most specific), then falls back to global (subjectType=null), then to the compiled-in default.
subjectId
string· nullable
When subjectType is set, the App.id or Space.id this preference applies to. null when this is a global preference. Polymorphic — no Prisma @relation.
More attributes+ Show 4
userId
string
The User.id whose preferences these are. Cascade-deletes when the user is deleted. Every preference row is per-user — there's no team-level or global default-override mechanism.
id
string
CUID primary key. The natural key is (userId, eventType, channel, subjectType, subjectId) — enforced by @@unique. The surrogate id is rarely needed outside Prisma internals.
createdAt
timestamp
ISO 8601 timestamp when the row was first written.
updatedAt
timestamp
ISO 8601 timestamp of the last enabled flip. Same row is updated in-place on subsequent toggles (no insert-and-supersede pattern).
THE NOTIFICATION PREFERENCE OBJECT
{
  "id": "pref_clxa",
  "userId": "usr_alex",
  "eventType": "FIELD_CHANGED",
  "channel": "IN_APP",
  "enabled": false,
  "subjectType": "APP",
  "subjectId": "app_noisy",
  "createdAt": "2026-06-07T10:00:00.000Z",
  "updatedAt": "2026-06-07T10:00:00.000Z"
}

Methods

Endpoints to operate on a Notification Preference. The example switches between curl, drop CLI, and MCP via the dropdown — your choice persists across the page.

GET

List notification preferences

/api/notification-preferences

Return the viewer's effective notification preferences: defaults (the compiled-in default matrix), overrides (explicit rows that diverge from defaults — global and per-subject), and mutedSubjects (subjects with all three channels disabled via per-subject overrides). Per the lookup order in lib/change-events/preferences.ts: per-subject row wins over global override row wins over default.

GET/api/notification-preferences
drop notifications prefs
Response200
{
  "defaults": {
    "FIELD_CHANGED": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "VERSION_PUBLISHED": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "SHARED_WITH_YOU": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "ACCESS_GRANTED": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "COMMENT": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "REPLY": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "MENTION": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "MIGRATION_AUDIENCE_CHANGE": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "DROPLET_ARCHIVED_BY_SPACE": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "DROPLET_PDF_DOWNLOADED": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    }
  },
  "overrides": [
    {
      "id": "pref_1",
      "eventType": "FIELD_CHANGED",
      "channel": "IN_APP",
      "enabled": false,
      "subjectType": "APP",
      "subjectId": "app_noisy",
      "subject": {
        "subjectType": "APP",
        "subjectId": "app_noisy",
        "name": "Noisy Dashboard",
        "slug": "noisy-dashboard"
      },
      "createdAt": "2026-06-07T10:00:00.000Z"
    }
  ],
  "mutedSubjects": []
}
PATCH

Update a notification preference

/api/notification-preferences

Upsert a single preference cell. Targets one (eventType, channel) — globally when subject is omitted, or for a specific Droplet / Space when subject is set. Returns the resulting row. Idempotent — setting a row to its current value is a no-op.

Parameters

eventType
enum· body· required
Event type to set (FIELD_CHANGED, COMMENT, etc.).
channel
enum· body· required
IN_APP | EMAIL | SLACK.
enabled
boolean· body· required
Route this event to this channel?
subject
object· body
{ subjectType: APP|SPACE, subjectId } to scope this preference; omit for global.
PATCH/api/notification-preferences
drop notifications prefs set --event-type FIELD_CHANGED --channel EMAIL --enabled
Response200
{
  "id": "pref_2",
  "eventType": "FIELD_CHANGED",
  "channel": "EMAIL",
  "enabled": true,
  "subjectType": null,
  "subjectId": null,
  "subject": null,
  "createdAt": "2026-06-07T15:30:00.000Z"
}
POST

Mute a subject across all channels

/api/notification-preferences/mute-subject

Convenience: write three NotificationPreference rows in one transaction — one per channel (IN_APP, EMAIL, SLACK) all with enabled=false, scoped to the given (subjectType, subjectId). Equivalent to three update calls but atomic. Idempotent — already-muted subjects return rowsWritten: 0. The same subject can also be muted via a Watch row (source: MUTED); the two paths are independent (Watch.mute suppresses fan-out for that user; this suppresses delivery via channel preferences). The UI surfaces both as "mute" — see data-model.md § What the data model does NOT include for the design rationale.

Parameters

subjectType
enum· body· required
APP | SPACE.
subjectId
string· body· required
App.id or Space.id (slug also accepted).
POST/api/notification-preferences/mute-subject
drop notifications mute --subject-type APP --subject-id app_noisy
Response200
{
  "subjectType": "APP",
  "subjectId": "app_noisy",
  "rowsWritten": 3
}
DELETE

Un-mute a subject

/api/notification-preferences/mute-subject

Delete every enabled=false NotificationPreference row scoped to the given (subjectType, subjectId) for the viewer. Inverse of muteSubject. Idempotent — un-muting a non-muted subject returns rowsDeleted: 0. Note this only removes preference-level mutes; if the viewer also has a Watch row with source: MUTED, call unwatchApp / unwatchSpace separately to fully restore notifications.

Parameters

subjectType
enum· body· required
APP | SPACE.
subjectId
string· body· required
App.id or Space.id (slug also accepted).
DELETE/api/notification-preferences/mute-subject
drop notifications unmute --subject-type APP --subject-id app_noisy
Response200
{
  "subjectType": "APP",
  "subjectId": "app_noisy",
  "rowsDeleted": 3
}