The inbox
How the bell reads notifications back — the five endpoints, what each is for, and the three different ways to mark something read.
The inbox is what a user sees when they click the bell: their notifications, newest first, with unread ones marked. This page covers the API endpoints behind it.
All five live under /api/v1/notifications.
Everything is scoped to you
Every endpoint works out who is asking from their login token, and only ever touches that person's rows:
list(@Query() query, @CurrentUser('sub') userId: string) {
return this.notificationService.list(userId, query);
}@CurrentUser('sub') pulls the user id out of the JWT. There is no user id in the URL or the query string, so there is no way to ask for someone else's notifications — not by changing a parameter, not by guessing an id.
This is also why there is no permission check on these routes. Permission checks exist to decide who may see what; here the answer is always "your own", enforced by the query itself. Asking to mark someone else's notification read returns a 404, which also avoids confirming that it exists.
The five endpoints
| Endpoint | What it is for |
|---|---|
GET /notifications | The list behind the bell dropdown |
GET /notifications/unread-count | The number on the badge |
PATCH /notifications/:id/read | User clicked one notification |
POST /notifications/read-all | User clicked "Mark all read" |
POST /notifications/read-by-context | User opened the thing a notification was about |
What counts as being in the inbox
Not every notification appears in the bell. The list and the count both filter on this:
private inboxWhere(userId: string) {
return {
recipientUserId: userId,
channels: ArrayContains([NotificationChannel.IN_APP]),
};
}In plain terms: rows belonging to this user, where the saved channel list includes in_app.
So a WhatsApp-only notification like guardian.welcome does get a notification row — but it never shows in anyone's bell, because in_app was not one of its channels.
This is the reason each row stores its own copy of the channel list. If the filter checked the current channel matrix instead, then adding the bell to guardian.welcome tomorrow would make every guardian welcome ever sent suddenly appear in inboxes.
Listing
The list is cursor-paginated, the same as every other list in the API — see Querying & Pagination. It adds one option of its own:
@ApiPropertyOptional({ default: false })
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
unreadOnly?: boolean;unreadOnly=true returns only unread notifications.
Why the @Transform is there. Query strings are always text. Without it, ?unreadOnly=false would arrive as the string 'false' — which JavaScript treats as truthy — and you would get the opposite of what you asked for. The transform converts it to a real boolean first.
Results come back newest first. There is a database index on (recipient_user_id, read_at, created_at) that serves all three needs at once: finding your rows, filtering to unread, and sorting by date.
Three ways to mark read
One at a time
PATCH /notifications/:id/read — used when the user clicks a single notification.
If the notification is already read, nothing is written and nothing is broadcast. Only a real change from unread to read does any work.
All at once
POST /notifications/read-all — the "Mark all read" button. Marks every unread notification for that user. Again, if there was nothing unread, nothing happens.
By context — the interesting one
POST /notifications/read-by-context handles this situation: a user has three notifications about the same scheme group. They open that scheme group. All three should clear — and the Hub should not need to know which three.
You send the type and a fragment of the data to match:
{ "type": "scheme_group.invite", "match": { "groupId": "abc-123" } }That marks read every unread scheme_group.invite for this user whose data contains groupId: "abc-123" — whether that is one notification or five.
How the matching works. data is stored as JSONB, and Postgres has a "contains" operator for it:
.andWhere('type = :type', { type: dto.type })
.andWhere('data @> :match::jsonb', { match: JSON.stringify(dto.match) })data @> '{"groupId":"abc-123"}' is true when the stored JSON contains that key and value, regardless of what else is in there.
This is the one query written with a raw query builder instead of the usual repository helper, because JSONB containment cannot be expressed through it. Ownership is still enforced in the SQL itself (recipient_user_id = :userId), and the school is added to the condition when there is one.
Keeping other devices in sync
When something is marked read, the API sends a message on that user's personal channel — the same channel used for new notifications, but with every field set to null:
await this.realtime.publishTo('personal', { userId }, 'notification', {
notificationId: null, type: null, title: null, body: null, data: null,
});Why the same event with nulls, rather than a different event? So clients only have to listen for one thing. They tell the two cases apart by checking notificationId:
- It has a value → a new notification arrived. Show a toast, refresh the bell.
- It is
null→ this user read something somewhere else. Refresh the bell, show nothing.
Without this, reading a notification on your phone would leave the badge wrong on your laptop until you reloaded.
The Hub side of this is in Notifications in the Hub.