Skip to main content

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 newly created notification carries a type, a stable variant key in data.key, a stored title, an optional stored message, additional data (JSONB), and an optional actions array defining available user actions. Older unmatched rows can lack data.key. The stored title and message normally provide English fallbacks; messages containing user text or server-localized content retain that content. 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.

Read state and display text​

The bell fetches the latest ten notifications when it opens with unread items, then calls the existing bulk read mutation for all of the user's unread notifications, including those outside the ten shown. The unread badge clears immediately. Items that were unread when the bell opened remain highlighted for that open session. Other mounted bells clear their badges through the lumio:notifications-read window event. A failed bulk read refetches the count; if that also fails, the count from opening is restored. Opening with no unread items sends no bulk read request. The per-item read endpoint remains available to API clients and the Ideas Hub view action; there is no per-item or bulk read button in the bell. Delete all remains available.

type names the notification category, while data.key identifies its text variant. For example, security_mfa_changed covers both security_mfa_enabled and security_mfa_disabled. apps/api/src/services/notification_types.rs defines the variant keys in keys and lists them in ALL_IN_APP_KEYS; this is separate from notification_types::ALL, the delivery-preference registry. db::notifications::create_notification requires a key and stores it in data.key. Every value interpolated into a title or message must also live in data, so the UI can render it in the selected language. The English title and message remain stored as fallbacks.

The bell resolves text in the current UI language through apps/web/src/lib/notification-text.ts: notifications.types.<data.key> first, notifications.types.<type> next, then the stored title and message. A missing translation or missing interpolation value falls back to the stored text for that field. Messages with user-written text or server-localized content can have no translated message template and pass through unchanged. Action labels use notifications.actions.<action>. This UI language lookup is independent of the recipient-language rules for email.

The notification variant-key migration fills data.key on existing rows that match a known type, English title, and data shape; it marks those rows with data.key_source: "backfill". Unmatched rows keep their stored text. For each new in-app variant, define a new key in ALL_IN_APP_KEYS, supply English and German text under notifications.types.<key> in apps/web/messages/{en,de}.json, and include every interpolated value in data. The registry test checks that every listed key has a title translation in both languages.

Actionable Notifications​

Some notifications include an actions array in JSONB format:

[
{ "action": "accept_invite", "label": "Accept" },
{ "action": "decline_invite", "label": "Decline" }
]

An action with a url opens a page from the bell. The bell marks an unread notification as read, then follows a same-origin path beginning with / or an absolute HTTP(S) URL. It ignores unsafe URLs, including protocol-relative // paths and non-HTTP schemes. It does not call executeNotificationAction for a navigation action. Navigation actions include view_idea, view_developer_application, view_developer, view_extension, and install_extension.

An action without a url is sent to the action endpoint. The server checks that the notification belongs to the caller, that the action appears on the notification, and that it has not already been handled. It dispatches four invite actions:

ActionEffect
accept_inviteAdds the user to the invited account with the invite's role. The bell switches to that account and opens /dashboard.
decline_inviteDeletes the account invite. If it is already gone, the notification is still handled.
accept_team_inviteAdds the user to the developer team with the invite's role, subject to the extension-development plan gate, expiry, use limit, and membership checks. The team owner is notified; the bell opens /dashboard/extensions/teams/\{team_slug\}.
decline_team_inviteDeletes an unused single-use invite. A multi-use or already-used invite remains available; an invite that is already gone is a no-op. The inviter is not notified.

A successful dispatched action sets acted_at and read_at if needed. Its buttons disappear immediately in the bell, and the list is refreshed. A failed action shows an error toast with the server message. The API rejects a navigation action sent to the action endpoint with 400 NOTIFICATION_ACTION_NAVIGATES; see REST errors and GraphQL errors.

Notification Preferences​

Users configure per-type delivery under Settings → Notifications (/dashboard/settings/notifications). Preferences are stored in the user_notification_preferences table. REST and GraphQL read the same registry in apps/api/src/services/notification_types.rs, including its locked flag, default channel and owner-only flag.

Email appearance and opt-out​

Email uses the C · Brief layout: a light letter with a text wordmark, a colored category line, a heading, fine separators, and a clear action button. Supporting mail clients show Lumio's dark colors; Gmail's mobile apps normally keep the light letter unchanged when the light only color setting is honored. Each message also includes its own inbox preview and a plain-text part. Images such as the logo, platform marks, and stream chart are embedded in the message. The footer explains why the message arrived and uses the recipient's language for the legal links.

The Manage notifications footer link appears only on email for a configurable notification type. It opens Settings → Notifications, where the recipient can choose Off, In-App, Email, or In-App + Email. Required or security notices are locked and cannot be switched off; their footer has no manage link. Direct transactional mail without a notification preference has no manage link either. The same notification registry determines both the setting and the footer link.

Channels​

ValueIn-app notificationEmail
offNoNo
in_appYesNo
emailNoYes
in_app_emailYesYes

When no preference row exists, a configurable type uses its registry default. Locked types always send in-app and email. An unrecognized stored value or preference lookup error fails open to both channels.

Email language and timezone​

Every email is written in the recipient's resolved language and renders dates in the recipient's resolved timezone - never the language of whoever triggered the notification. A broadcast (e.g. a developer-application email fanned out to several administrators) resolves per recipient, so each administrator gets their own language.

Resolution is one rule, applied server-side in apps/api/src/user_preferences.rs (resolve_email_locale / resolve_email_timezone):

  • Language = the user's locale if set to a concrete value (en / de), else the last browser-observed locale (detected_locale), else English.
  • Timezone = the user's timezone if set to a concrete IANA name, else the last browser-observed timezone (detected_timezone), else UTC.

The auto sentinel means "follow the browser". A server-side mailer (a cron summary, a worker) has no browser request, so auto cannot be answered from a cookie or Accept-Language header; detected_locale / detected_timezone are the last values the web app observed and persisted (on change only, ZAF-1386) precisely so those mailers still land in the right language. A user who never opened the app in a browser resolves auto to English / UTC. An unknown or malformed stored value falls back to English (there is no German fallback template) rather than failing the send.

Numeric dates follow the user's date_format preset; month and weekday names follow the language. The stream-summary heading uses the recipient's timezone and time format for its time range. The recipient is always a users row (users.email); for stream_summary and reconnect_required that is the account owner (accounts.owner_id), not the actor who triggered it.

detected_locale / detected_timezone are readable on me / GET /v1/users/me and writable via updateMe / PATCH /v1/users/me, but they are not shown in the settings UI - they are an observation, not a user-facing preference.

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.

TypeHardcoded behaviour
invite, security_*, data_export_ready, account_deletion_scheduledAlways in-app + email
billing_payment_failed, billing_cancellation_receivedAlways in-app + email; visible only to the channel owner

Known types registry​

notification_types::ALL is what both protocols enumerate. Only unlocked types can be written:

TypeConfigurableDefaultDescription
security_new_login, security_mfa_changed (including an authenticator app or passkey added or removed), security_recovery_code_used, security_recovery_codes_regenerated, security_password_changed, security_login_method_changed, security_failed_attemptsNoin-app + emailPersonal security notices
inviteNoin-app + emailAccount invitation
account_role_changed, account_member_removed, support_identity_checkedYesin-app or bothAccount and support notices
data_export_ready, account_deletion_scheduledNoin-app + emailRequired privacy notices
stream_summary, connection_reconnect_requiredYesin-app + emailStream and connection notices
billing_invoice_paid, billing_plan_changedYes; owner onlyin-app + emailBilling notices
billing_payment_failed, billing_cancellation_receivedNo; owner onlyin-app + emailRequired billing notices
idea_mentionYesin-app + emailIdeas Hub mention

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. shopify_data_request (in-app only, to the channel owner, variant keys shopify_data_request / _partial / _none; see Shopify → Compliance webhooks) is written directly too and cannot be switched off: the owner has to answer the customer.

Database​

Table: user_notification_preferences

ColumnTypeDescription
idUUID (PK)Row ID
user_idUUID (FK)Owning user
notification_typeVARCHAR(50)Notification type key (e.g. idea_mention)
channelVARCHAR(20)Delivery channel; CHECK constrained to off, in_app, email, or in_app_email
updated_atTIMESTAMPTZLast write

UNIQUE (user_id, notification_type); writes go through an upsert on that pair.

API​

GraphQL:

OperationArgsReturnsPermission
notificationPreferences-[NotificationPreference!]!Auth only (first-party)
updateNotificationPreferencenotificationType: 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:

MethodPathPermissionDescription
GET/v1/notifications/preferencesAuthList one preference entry per known type for the current user
PATCH/v1/notifications/preferences/{notification_type}AuthSet 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:

  1. a locked type → "System notifications cannot be configured"
  2. any type outside the registry → "Unknown notification type"
  3. 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:

{ "key": "invite", "inviteId": "<uuid>", "accountId": "<uuid>", "account_id": "<uuid>", "inviter_name": "<name>", "account_name": "<name>", "role_name": "<role>" }

actions:

actionEffect
accept_inviteAdds the user to the account with the invite's role; marks notification acted.
decline_inviteDeletes the account_invites row (the invite is revoked); marks notification acted.

If an account invite was removed before the recipient declines it, the decline still marks the notification handled and writes no decline audit event.

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):

{ "key": "idea_mention", "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 bell opens this page; the action endpoint does not dispatch it.

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:

TypeEmitted when
developer_applicationA developer application is submitted, approved, or rejected
developer_team_inviteA developer-team invite is created or accepted
developer_team_memberA developer-team member is removed or has their role changed
extension_lifecycleAn extension is registered, a version is submitted/approved/rejected/published, or an extension is suspended
extension_accessAccess to an extension is granted or revoked for a user
extension_testerA tester is invited or removed, or tester feedback is submitted

An addressed developer_team_invite notification contains data.invite_id and data.team_slug and offers accept_team_invite / decline_team_invite. Its invite code stays out of the notification. The emailed invite link opens /invite/dev/\{code\} on the configured web app origin, where the recipient can accept by code. Both acceptance paths apply the same plan and invite validity checks.

API​

GraphQL Queries​

QueryArgsReturnsPermission
notificationslimit: Int, offset: IntNotificationList!Auth only (first-party)
unreadNotificationCount--Int!Auth only (first-party)
notificationPreferences--[NotificationPreference!]!Auth only (first-party)

NotificationList includes:

  • items: [Notification!]! -- Paginated list ordered by created_at DESC
  • total: Int! -- Total notification count for pagination
  • unreadCount: Int! -- Count of unread notifications

Default limit is 25, capped at 100; offset defaults to 0.

GraphQL Mutations​

MutationArgsReturnsPermission
markNotificationReadid: UUID!Notification!Auth only (first-party)
markAllNotificationsRead--MarkAllReadResult!Auth only (first-party)
deleteAllNotifications--MarkAllReadResult!Auth only (first-party)
executeNotificationActionid: UUID!, action: String!Notification!Auth only (first-party)
updateNotificationPreferencenotificationType: 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.

MethodPathPermissionDescription
GET/v1/notificationsAuthPaginated list; query params are page (1-based) and limit (default 25, clamped 1–100)
PATCH/v1/notifications/{id}/readAuthMark a notification as read
POST/v1/notifications/{id}/actionAuthExecute accept_invite, decline_invite, accept_team_invite, or decline_team_invite
POST/v1/notifications/read-allAuthMark all notifications as read
POST/v1/notifications/delete-allAuthDelete all of the current user's notifications
GET/v1/notifications/preferencesAuthList one preference entry per known type for the current user
PATCH/v1/notifications/preferences/{notification_type}AuthSet 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​

ColumnTypeDescription
idUUID (PK)Notification ID
user_idUUID (FK, ON DELETE CASCADE)Target user
typeVARCHAR(50)Notification type (e.g., invite)
titleVARCHAR(255)English fallback title
messageTEXTOptional stored detail message; English fallback or pass-through content
dataJSONB (default '{}')Variant key in key plus interpolation and action data
actionsJSONBOptional array of available actions
read_atTIMESTAMPTZWhen marked as read
acted_atTIMESTAMPTZWhen an action was executed
created_atTIMESTAMPTZCreation 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:

FunctionDescription
list_notificationsPaginated list for a user, ordered by created_at DESC
count_notificationsTotal count for a user
count_unreadCount where read_at IS NULL
get_notificationSingle notification by ID
mark_as_readSets read_at = now()
mark_all_readBulk update all unread for a user
delete_allDelete every notification for a user
mark_actedSets acted_at = now() and read_at = COALESCE(read_at, now())
mark_revoked_by_invite_idResolve outstanding invite notifications when the backing invite disappears
create_notificationInsert a new notification
delete_notificationDelete by ID

apps/api/src/db/notification_preferences.rs:

FunctionDescription
get_preferencesAll stored preference rows for a user, ordered by type
get_preferenceOne stored row for a (user, type) pair
upsert_preferenceInsert or update the channel for a (user, type) pair
should_notifyResolve (send_in_app, send_email) for a (user, type) pair; fail-open

Key Files​

FilePurpose
apps/api/src/graphql/notifications.rsGraphQL queries, mutations, preference operations, and action dispatch
apps/api/src/routes/notifications.rsREST handlers, known-type registry, and action dispatch
apps/api/src/db/notifications.rsDatabase CRUD operations for notifications
apps/api/src/db/notification_preferences.rsPreference storage and the should_notify helper
apps/api/src/services/invite_notifications.rsCreates the invite notification and its email side channel
apps/api/src/services/idea_mentions.rsCreates idea_mention notifications and emails
apps/web/src/app/(main)/(app)/notification-bell.tsxNotification bell and dropdown
apps/web/src/app/api/notifications/Next.js proxy routes for notifications and preferences
crates/lo-auth/src/rbac.rsPermission registry (notification permissions were dropped - auth only)