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:
| Action | Effect |
|---|---|
accept_invite | Adds the user to the invited account with the invite's role. The bell switches to that account and opens /dashboard. |
decline_invite | Deletes the account invite. If it is already gone, the notification is still handled. |
accept_team_invite | Adds 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_invite | Deletes 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
| Value | In-app notification | |
|---|---|---|
off | No | No |
in_app | Yes | No |
email | No | Yes |
in_app_email | Yes | Yes |
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
localeif set to a concrete value (en/de), else the last browser-observed locale (detected_locale), else English. - Timezone = the user's
timezoneif 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.
| Type | Hardcoded behaviour |
|---|---|
invite, security_*, data_export_ready, account_deletion_scheduled | Always in-app + email |
billing_payment_failed, billing_cancellation_received | Always 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:
| Type | Configurable | Default | Description |
|---|---|---|---|
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_attempts | No | in-app + email | Personal security notices |
invite | No | in-app + email | Account invitation |
account_role_changed, account_member_removed, support_identity_checked | Yes | in-app or both | Account and support notices |
data_export_ready, account_deletion_scheduled | No | in-app + email | Required privacy notices |
stream_summary, connection_reconnect_required | Yes | in-app + email | Stream and connection notices |
billing_invoice_paid, billing_plan_changed | Yes; owner only | in-app + email | Billing notices |
billing_payment_failed, billing_cancellation_received | No; owner only | in-app + email | Required billing notices |
idea_mention | Yes | in-app + email | Ideas 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
| 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:
- a locked type →
"System notifications cannot be configured" - any type outside the registry →
"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:
{ "key": "invite", "inviteId": "<uuid>", "accountId": "<uuid>", "account_id": "<uuid>", "inviter_name": "<name>", "account_name": "<name>", "role_name": "<role>" }
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. |
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:
| 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 |
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
| 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 accept_invite, decline_invite, accept_team_invite, or decline_team_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) | English fallback title |
message | TEXT | Optional stored detail message; English fallback or pass-through content |
data | JSONB (default '{}') | Variant key in key plus interpolation and action data |
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) |