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:
- Per-subject row (
subjectType+subjectIdset) — most specific. "Mute FIELD_CHANGED on the noisy dashboard, on every channel." - Global override (
subjectTypeandsubjectIdbothnull) — "Always send FIELD_CHANGED to email." - 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 type | IN_APP | SLACK | |
|---|---|---|---|
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
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).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).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.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.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− Hide
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.(userId, eventType, channel, subjectType, subjectId) — enforced by @@unique. The surrogate id is rarely needed outside Prisma internals.enabled flip. Same row is updated in-place on subsequent toggles (no insert-and-supersede pattern).{
"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.
List 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.
drop notifications prefs{
"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": []
}Update a notification preference
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
{ subjectType: APP|SPACE, subjectId } to scope this preference; omit for global.drop notifications prefs set --event-type FIELD_CHANGED --channel EMAIL --enabled{
"id": "pref_2",
"eventType": "FIELD_CHANGED",
"channel": "EMAIL",
"enabled": true,
"subjectType": null,
"subjectId": null,
"subject": null,
"createdAt": "2026-06-07T15:30:00.000Z"
}Mute a subject across all channels
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
drop notifications mute --subject-type APP --subject-id app_noisy{
"subjectType": "APP",
"subjectId": "app_noisy",
"rowsWritten": 3
}Un-mute a 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
drop notifications unmute --subject-type APP --subject-id app_noisy{
"subjectType": "APP",
"subjectId": "app_noisy",
"rowsDeleted": 3
}