Notifications
Overview
The notification system delivers in-app notifications to users with read/unread tracking and actionable items. Notifications are user-scoped (not account-scoped), supporting typed messages with optional actions such as accepting or declining team invites.
Each notification carries a type, a title, an optional message, arbitrary data (JSONB), and an optional actions array defining available user actions. Notifications track both read_at and acted_at timestamps to distinguish between viewed and resolved states.
Architecture
Dashboard UI
|
v
Next.js API Proxy (/api/notifications)
|
v
GraphQL (NotificationQuery / NotificationMutation)
|
v
db::notifications (PostgreSQL)
Notifications are created server-side (e.g., when a team invite is sent) and consumed by the frontend via GraphQL queries. The notification list includes an unread count (unreadCount on GraphQL, _meta.unread_count on REST) for badge display without fetching all items.
Every notification read/write path — GraphQL and REST alike — runs require_first_party_user. A popout, overlay, shared-overlay, widget, or extension token bound to the owner is rejected: a user's notifications are private to that user (ZAF-469).
Notifications have no WebSocket channel; channel_gate_for in crates/lo-websocket/src/gate.rs defines no notifications channel type. The bell polls the query instead.
Actionable Notifications
Some notifications include an actions array in JSONB format:
[
{ "action": "accept_invite", "label": "Accept" },
{ "action": "decline_invite", "label": "Decline" }
]
When a user executes an action, the system validates the action exists in the notification's actions array, then dispatches to the appropriate handler. Currently supported actions:
accept_invite-- Accepts a team invite by looking updata.inviteId, retrieving the invite, and callingdb::members::accept_inviteto add the user as an account member with the invited role.decline_invite-- Deletes the backingaccount_invitesrow (the invite is revoked) and marks the notification as acted.
After action execution, the notification is marked with acted_at and read_at (if not already read).
Notification Preferences
Users configure per-type delivery in the Notifications section of Dashboard → Settings (/dashboard/settings). Preferences are stored in the user_notification_preferences table and read at notification-dispatch time via the should_notify(db, user_id, type) helper, which returns (send_in_app: bool, send_email: bool).
Channels
| Value | In-app notification | |
|---|---|---|
off | No | No |
in_app | Yes | No |
email | No | Yes |
in_app_email | Yes | Yes |
should_notify is fail-open: when no preference row exists, when the stored channel value is unrecognized, or when the preference lookup errors, it returns (true, true) — the equivalent of in_app_email.
System-critical types
Certain types are hardcoded to always deliver and cannot be configured by the user. should_notify short-circuits to (true, true) for these before any lookup.
| Type | Hardcoded behaviour |
|---|---|
invite | Always in-app + email |
Known types registry
KNOWN_NOTIFICATION_TYPES — declared identically in apps/api/src/graphql/notifications.rs and apps/api/src/routes/notifications.rs — is what the preferences surface enumerates. Only these types appear in the settings UI, and only the unlocked ones can be written:
| Type | Configurable | Default | Description |
|---|---|---|---|
invite | No (locked) | in-app + email | Account team invite |
idea_mention | Yes | in-app + email | @mention in an Ideas Hub comment |
stream_summary | Yes | in-app + email | Stream-history summary after a session ends |
Other notification types are emitted by the platform but are not in this registry, so they have no preference row and always deliver at the fail-open default: developer_application, developer_team_invite, developer_team_member, extension_access, extension_lifecycle, and extension_tester.
Database
Table: user_notification_preferences
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Row ID |
user_id | UUID (FK) | Owning user |
notification_type | VARCHAR(50) | Notification type key (e.g. idea_mention) |
channel | VARCHAR(20) | Delivery channel; CHECK constrained to off, in_app, email, or in_app_email |
updated_at | TIMESTAMPTZ | Last write |
UNIQUE (user_id, notification_type); writes go through an upsert on that pair.
API
GraphQL:
| Operation | Args | Returns | Permission |
|---|---|---|---|
notificationPreferences | — | [NotificationPreference!]! | Auth only (first-party) |
updateNotificationPreference | notificationType: String!, channel: String! | NotificationPreference! | Auth only (first-party) |
The read merges the registry with the user's stored rows, so it always returns one entry per known type — including locked: true for invite, which is reported as in_app_email regardless of any stored row.
REST:
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/notifications/preferences | Auth | List one preference entry per known type for the current user |
PATCH | /v1/notifications/preferences/{notification_type} | Auth | Set the delivery channel for a notification type |
PATCH body: { "channel": "off" | "in_app" | "email" | "in_app_email" }. Both protocols apply the same three validations, in order, with identical messages:
invite→"System notifications cannot be configured"- any type outside
idea_mention/stream_summary→"Unknown notification type" - any other channel value →
"Invalid channel. Must be one of: off, in_app, email, in_app_email"
On REST these surface as 400; on GraphQL as a resolver error.
Notification types
invite
Fired when a member invite is created with invited_user_id (user-search invite flow) in the members dashboard. The invitee gets the in-dashboard notification immediately; in addition, if the invitee has an email on record (users.email IS NOT NULL) the same AccountInvite transactional template that the email-invite branch uses is sent as a fire-and-forget side channel.
data shape:
{ "inviteId": "<uuid>", "accountId": "<uuid>" }
actions:
action | Effect |
|---|---|
accept_invite | Adds the user to the account with the invite's role; marks notification acted. |
decline_invite | Deletes the account_invites row (the invite is revoked); marks notification acted. |
idea_mention
Fired when another user @mentions the recipient in an Ideas Hub comment. Delivery channel is determined by the recipient's idea_mention preference row (fail-open default: in-app + email). Dispatched fire-and-forget from apps/api/src/services/idea_mentions.rs.
data shape (keys are snake_case as stored):
{ "idea_id": "<uuid>", "author_name": "<display_name>", "idea_title": "<title>" }
actions: a single entry, { "label": "View", "action": "view_idea", "url": "/hub/ideas/<idea_id>" }. The action is a client-side link target — executeNotificationAction only dispatches accept_invite and decline_invite.
stream_summary
Fired after a stream session ends, delivering the history summary to the account owner. Configurable via the stream_summary preference key. Emitted by apps/api/src/services/history/summary_email.rs.
Types outside the preferences registry
These types are created by their respective subsystems, are not user-configurable, and always deliver at the fail-open default:
| Type | Emitted when |
|---|---|
developer_application | A developer application is submitted, approved, or rejected |
developer_team_invite | A developer-team invite is created or accepted |
developer_team_member | A developer-team member is removed or has their role changed |
extension_lifecycle | An extension is registered, a version is submitted/approved/rejected/published, or an extension is suspended |
extension_access | Access to an extension is granted or revoked for a user |
extension_tester | A tester is invited or removed, or tester feedback is submitted |
API
GraphQL Queries
| Query | Args | Returns | Permission |
|---|---|---|---|
notifications | limit: Int, offset: Int | NotificationList! | Auth only (first-party) |
unreadNotificationCount | -- | Int! | Auth only (first-party) |
notificationPreferences | -- | [NotificationPreference!]! | Auth only (first-party) |
NotificationList includes:
items: [Notification!]!-- Paginated list ordered bycreated_at DESCtotal: Int!-- Total notification count for paginationunreadCount: Int!-- Count of unread notifications
Default limit is 25, capped at 100; offset defaults to 0.
GraphQL Mutations
| Mutation | Args | Returns | Permission |
|---|---|---|---|
markNotificationRead | id: UUID! | Notification! | Auth only (first-party) |
markAllNotificationsRead | -- | MarkAllReadResult! | Auth only (first-party) |
deleteAllNotifications | -- | MarkAllReadResult! | Auth only (first-party) |
executeNotificationAction | id: UUID!, action: String! | Notification! | Auth only (first-party) |
updateNotificationPreference | notificationType: String!, channel: String! | NotificationPreference! | Auth only (first-party) |
All mutations verify user ownership -- a user can only interact with their own notifications. A notification belonging to another user is reported as not found, never as forbidden.
REST Endpoints
All paths live under /v1. Notifications are user-scoped — authentication is enough, no resource:action guard.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/notifications | Auth | Paginated list; query params are page (1-based) and limit (default 25, clamped 1–100) |
PATCH | /v1/notifications/{id}/read | Auth | Mark a notification as read |
POST | /v1/notifications/{id}/action | Auth | Execute a notification action (accept_invite or decline_invite) |
POST | /v1/notifications/read-all | Auth | Mark all notifications as read |
POST | /v1/notifications/delete-all | Auth | Delete all of the current user's notifications |
GET | /v1/notifications/preferences | Auth | List one preference entry per known type for the current user |
PATCH | /v1/notifications/preferences/{notification_type} | Auth | Set the delivery channel for a notification type |
GET /v1/notifications is the one REST endpoint whose envelope differs from the GraphQL shape: it returns { "data": [ … ], "_meta": { "total", "unread_count", "page", "limit", "pages" } }, where GraphQL nests the same values under NotificationList.
GraphQL Types
type Notification {
id: UUID!
userId: UUID!
type: String!
title: String!
message: String
data: JSON!
actions: JSON
readAt: String
actedAt: String
createdAt: String!
}
type NotificationList {
items: [Notification!]!
total: Int!
unreadCount: Int!
}
type MarkAllReadResult {
updated: Int!
}
type NotificationPreference {
notificationType: String!
channel: String!
locked: Boolean!
}
Permissions
Notifications require authentication only (no account-level permissions), and the identity must be first-party. Any logged-in user can view and manage their own notifications. Ownership is enforced by the resolvers and handlers (user_id check).
Database
Table: notifications
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Notification ID |
user_id | UUID (FK, ON DELETE CASCADE) | Target user |
type | VARCHAR(50) | Notification type (e.g., invite) |
title | VARCHAR(255) | Display title |
message | TEXT | Optional detail message |
data | JSONB (default '{}') | Arbitrary payload (e.g., { "inviteId": "..." }) |
actions | JSONB | Optional array of available actions |
read_at | TIMESTAMPTZ | When marked as read |
acted_at | TIMESTAMPTZ | When an action was executed |
created_at | TIMESTAMPTZ | Creation timestamp |
A partial index idx_notifications_user_unread on (user_id, created_at) WHERE read_at IS NULL backs the unread-count badge.
DB Functions
apps/api/src/db/notifications.rs:
| Function | Description |
|---|---|
list_notifications | Paginated list for a user, ordered by created_at DESC |
count_notifications | Total count for a user |
count_unread | Count where read_at IS NULL |
get_notification | Single notification by ID |
mark_as_read | Sets read_at = now() |
mark_all_read | Bulk update all unread for a user |
delete_all | Delete every notification for a user |
mark_acted | Sets acted_at = now() and read_at = COALESCE(read_at, now()) |
mark_revoked_by_invite_id | Resolve outstanding invite notifications when the backing invite disappears |
create_notification | Insert a new notification |
delete_notification | Delete by ID |
apps/api/src/db/notification_preferences.rs:
| Function | Description |
|---|---|
get_preferences | All stored preference rows for a user, ordered by type |
get_preference | One stored row for a (user, type) pair |
upsert_preference | Insert or update the channel for a (user, type) pair |
should_notify | Resolve (send_in_app, send_email) for a (user, type) pair; fail-open |
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/notifications.rs | GraphQL queries, mutations, preference operations, and action dispatch |
apps/api/src/routes/notifications.rs | REST handlers, known-type registry, and action dispatch |
apps/api/src/db/notifications.rs | Database CRUD operations for notifications |
apps/api/src/db/notification_preferences.rs | Preference storage and the should_notify helper |
apps/api/src/services/invite_notifications.rs | Creates the invite notification and its email side channel |
apps/api/src/services/idea_mentions.rs | Creates idea_mention notifications and emails |
apps/web/src/app/(main)/(app)/notification-bell.tsx | Notification bell and dropdown |
apps/web/src/app/api/notifications/ | Next.js proxy routes for notifications and preferences |
crates/lo-auth/src/rbac.rs | Permission registry (notification permissions were dropped — auth only) |