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 | |
|---|---|---|---|---|
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.
Send one
Section titled “Send one”-
Name its type in
NOTIFICATION_TYPESinpackages/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;teamsOnlyhides 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 },}, -
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.
Where it leads
Section titled “Where it leads”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.
Live, on every device
Section titled “Live, on every device”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.
The API
Section titled “The API”| 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.
Test it
Section titled “Test it”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.