Skip to content

Notifications

A notification is for one person: “Mia joined Acme”, “You have used all 50 AI messages this month”. It is kept, so someone who is away sees it when they come back, under the bell in the web app and in the Notifications tab of the mobile app, with the unread ones counted. It can also go to their phone and their inbox, as each person chooses on the Notifications page of their settings.

The kit sends three:

Type To When Phone Email
invitation.received Someone with an account They are invited to a workspace (teams only) on never
member.joined The workspace’s owners, admins Someone accepts an invitation (teams only) on off
usage.limit_reached The workspace’s owners Everything included this month is used on on

“on” and “off” are what each person gets until they choose. “never” is not offered: the invitation is emailed already.

  1. Name its type in NOTIFICATION_TYPES in packages/contracts/src/notifications.ts, with what the settings page shows and where it goes by default. Leave a channel out for one it never goes to; teamsOnly hides it from the settings page with personal accounts.

    'export.ready': {
    label: 'Exports',
    description: 'When an export you started is ready.',
    channels: { push: true, email: false },
    },
  2. Send it where it happens, with NotificationsService, which every module can inject:

    await this.notifications.notify(user.id, {
    type: 'export.ready',
    title: 'Your export is ready',
    body: 'Projects, as a CSV file.',
    workspaceId: workspace.id,
    url: '/projects/exports',
    key: `export:${exportId}`,
    });

    Or to a workspace’s members with at least a role, but not whoever did it:

    await this.notifications.notifyMembers(workspace.id, notification, {
    role: 'admin',
    except: user.id,
    });

Inside database.transaction(), nothing is kept or sent unless the change is saved. With a key, sending it again does nothing: one notification per person and key.

The title and body are shown as they are, in the apps, on the phone and in the email: write them for the person who reads them, and never put a secret in one.

url is a page of the web app. With a workspaceId, it is under that workspace: /settings/members opens /w/acme/settings/members, or /app/settings/members with personal accounts. Without one, it is a page as it is, such as /accept-invitation/<id>. Opening a notification marks it read and goes there; one about a workspace the person has left opens nothing.

The mobile app finds its own screen for the page in apps/mobile/src/features/notifications/screen-for.ts. Add a line there for a page of yours that has a screen; one with none opens the list.

A push notification opens /notifications/<id> in the mobile app, and the email links to /notifications/<id> in the web app. Both mark it read and go on to where it leads.

A notification reaches the person’s open apps at once, over the realtime connection, whichever copy of the API they are connected to: the count goes up without a reload. Reading one on one device takes it off the count on the others.

Notifications are kept for 90 days, read or not, and are deleted with their workspace or their person.

Route Does
GET /api/me/notifications?before=<createdAt> The newest 20, the unread count, hasMore
POST /api/me/notifications/read Marks ids read, or all of them without
POST /api/me/notifications/:id/open Marks one read and answers it
GET /api/me/notification-preferences Every type and where it goes
PUT /api/me/notification-preferences/:type { push, email } for one type

Each needs a person signed in: an API key gets 403.

t.app.get(NotificationsService) sends one; the person reads it from /api/me/notifications. What went to their phone is in t.pushes.notifications(), and their email in t.outbox. See apps/api/test/notifications.e2e.test.ts.