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 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 field for badge display without fetching all items.

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 up data.inviteId, retrieving the invite, and calling db::members::accept_invite to add the user as an account member with the invited role.
  • decline_invite -- Deletes the backing account_invites row (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 can configure per-type delivery in Settings → Notifications. 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

ValueIn-app notificationEmail
offNoNo
in_appYesNo
in_app_emailYesYes

When no preference row exists for a user/type combination, the default channel is in_app_email.

System-critical types

Certain types are hardcoded to always deliver and cannot be configured by the user. should_notify returns (true, true) for these regardless of any stored preference row.

TypeHardcoded behaviour
inviteAlways in-app + email

Known types registry

TypeConfigurableDefault
inviteNo (locked)in-app + email
idea_mentionYesin-app + email

Database

Table: user_notification_preferences

ColumnTypeDescription
user_idUUID (FK)Owning user
notification_typeTEXTNotification type key (e.g. idea_mention)
channelTEXTDelivery channel: off, in_app, or in_app_email

Primary key is (user_id, notification_type).

API

GraphQL:

OperationArgsReturnsPermission
notificationPreferences[NotificationPreference!]!Auth only
updateNotificationPreference(notificationType: String!, channel: String!)NotificationPreference!Auth only

REST:

MethodPathPermissionDescription
GET/v1/notifications/preferencesAuthList all preference rows for the current user
PATCH/v1/notifications/preferences/{type}AuthSet the delivery channel for a notification type

PATCH body: { "channel": "off" | "in_app" | "in_app_email" }. Rejects unknown channel values with 400.

Attempting to update a locked type (e.g. invite) returns 400 "Notification type cannot be configured".

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:

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.

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 (default: in_app_email).

data shape:

{ "ideaId": "<uuid>", "commentId": "<uuid>", "mentionedBy": "<display_name>" }

No actions array — the notification links directly to the idea detail page.

API

GraphQL Queries

QueryArgsReturnsPermission
notificationslimit?: Int, offset?: IntNotificationListAuth only
unreadNotificationCount--IntAuth only
notificationPreferences--[NotificationPreference!]!Auth only

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, max is 100.

GraphQL Mutations

MutationArgsReturnsPermission
markNotificationReadid: UUIDNotificationAuth only
markAllNotificationsRead--MarkAllReadResultAuth only
executeNotificationActionid: UUID, action: StringNotificationAuth only
updateNotificationPreferencenotificationType: String!, channel: String!NotificationPreference!Auth only

All mutations verify user ownership -- a user can only interact with their own notifications.

REST Endpoints

All paths live under /v1. Notifications are user-scoped — authentication is enough, no resource:action guard.

MethodPathPermissionDescription
GET/v1/notificationsAuthPaginated list (supports limit, offset) with unread_count
PATCH/v1/notifications/{id}/readAuthMark a notification as read
POST/v1/notifications/{id}/actionAuthExecute a notification action (e.g., accept_invite, decline_invite)
POST/v1/notifications/read-allAuthMark all notifications as read
GET/v1/notifications/preferencesAuthList all notification preference rows for the current user
PATCH/v1/notifications/preferences/{type}AuthSet the delivery channel for a notification type

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 {
userId: UUID!
notificationType: String!
channel: String!
}

Permissions

Notifications require authentication only (no account-level permissions). Any logged-in user can view and manage their own notifications. Ownership is enforced by the resolvers (user_id check).

Database

Table: notifications

ColumnTypeDescription
idUUID (PK)Notification ID
user_idUUID (FK)Target user
typeTEXTNotification type (e.g., invite)
titleTEXTDisplay title
messageTEXTOptional detail message
dataJSONBArbitrary payload (e.g., { "inviteId": "..." })
actionsJSONBOptional array of available actions
read_atTIMESTAMPTZWhen marked as read
acted_atTIMESTAMPTZWhen an action was executed
created_atTIMESTAMPTZCreation timestamp

DB Functions

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
mark_actedSets acted_at = now() and read_at = COALESCE(read_at, now())
create_notificationInsert a new notification
delete_notificationDelete by ID

Key Files

FilePurpose
apps/api/src/graphql/notifications.rsGraphQL queries, mutations, preference operations, and action dispatch
apps/api/src/db/notifications.rsDatabase CRUD operations for notifications and preferences
apps/web/src/app/api/notifications/preferences/Next.js proxy routes for notification preferences
crates/lo-auth/src/rbac.rsPermission constants (notifications removed — auth only)