Notifications
What a notification is, what happens from the moment one is triggered, and the vocabulary you need before you add one.
A notification is a message the system sends to a person to tell them something happened. The bell icon in the Education Hub, the email that lands in a teacher's inbox, the WhatsApp message a parent receives — all three are notifications.
This page explains what happens when one is sent. If you just want to add a new one, the step-by-step guide is Sending a notification — but read this first, because that guide assumes the words below.
A real example, start to finish
Sarah is a Director of Studies. She invites Bean, a subject teacher, to help plan a scheme of work. Bean should find out about it.
Here is everything that happens, in order:
- Sarah clicks Invite in the Hub. That sends an HTTP request to the API.
SchemeGroupServicesaves the invitation to the database. This is the real work, and it is now done.- That same service calls one method —
notificationService.emit(...)— and passes it four things: what happened (a scheme group invite), which school, who should be told (Bean), and the details (the subject, the class, and Sarah's name). - The notification system looks up what happened in a table called the channel matrix, which says: a scheme group invite goes to the in-app bell and by email.
- It writes the words Bean will see. Title: "Scheme of work invitation". Body: "Sarah Nakato invited you to plan Physics for S2 Blue together."
- It saves a row in the
notificationtable. This row is Bean's copy of the message. - Because email is one of the channels, it also saves a row in the
notification_deliverytable to track that email specifically. - It sends a real-time message to Bean's browser. If Bean has the Hub open, a toast pops up immediately.
- It hands an email to the email service, which queues it for sending.
Steps 4 to 9 all happen inside that single emit call from step 3. The service that saved the invitation does not know or care about channels, templates, or queues.
The words you need
Five terms are used constantly in these pages. They are easy to mix up, so here they are in one place.
| Term | What it means |
|---|---|
| Notification type | What happened, as a fixed value. scheme_group.invite is a type. So is class_teacher.assigned. There are only ever as many types as we have written. |
| Channel | How a person is reached. There are exactly three: in_app (the bell), email, and whatsapp. |
| The channel matrix | A lookup table that answers "for this type, which channels do we use?" It lives in one file and is the first thing you edit when adding a type. |
| A notification row | One person's copy of one message, saved in the notification table. If five people are told about the same event, five rows are written. |
| A delivery row | A record that tracks one attempt to reach someone outside the app, saved in notification_delivery. Email and WhatsApp get delivery rows. The in-app bell does not — more on why below. |
The one method you call
Everything starts with emit. This is the only method a normal feature ever calls:
export interface EmitNotificationInput<T extends NotificationType> {
type: T;
schoolId: string;
recipientUserIds: string[];
data: NotificationDataMap[T];
actorUserId?: string;
}Field by field:
| Field | What you pass | Example |
|---|---|---|
type | What happened | NotificationType.SCHEME_GROUP_INVITE |
schoolId | Which school this belongs to | The current school's id |
recipientUserIds | The user ids of everyone who should be told | [bean.userId] |
data | The facts needed to write the message and link to it | { subjectName: 'Physics', className: 'S2', … } |
actorUserId | The user id of the person who caused it — optional | Sarah's user id |
Why data is typed per type
data is not a free-form object. TypeScript checks it against the specific type you passed, using a lookup called NotificationDataMap:
export type NotificationDataMap = {
[NotificationType.SCHEME_GROUP_INVITE]: {
campusId: string;
groupId: string;
schemeOfWorkId: string;
subjectName: string;
className: string;
streamName: string;
invitedByName: string;
};
// …one entry per type
};So if you pass type: SCHEME_GROUP_INVITE, TypeScript demands exactly those seven fields. Pass the wrong shape and the build fails.
This matters more than it looks. That same map is later used to write the message text, to build the email, and to work out where the notification links to in the Hub. Because all of them read from one definition, none of them can drift apart.
Three things emit does for you
You do not have to handle any of these at the call site.
1. It can never break your feature
The entire body of emit is wrapped in a try/catch. If anything inside fails — the insert is rejected, the email service is down, WhatsApp times out — the error is logged and sent to Sentry, and then emit returns normally.
Why this exists: imagine Sarah clicks Invite, the invitation saves correctly, and then the email server happens to be down. Without this wrapper Sarah would see an error, and would reasonably assume the invitation failed — even though it worked. A notification is an extra. It must never make a real operation look broken.
The practical consequence for you: you never need a try/catch around emit.
2. It removes the person who caused it
Before doing anything, emit removes duplicates from recipientUserIds, and then removes actorUserId from the list.
Why: Sarah invited Bean. Sarah does not need a notification telling her that Sarah invited Bean — she just did it and is looking at the screen.
The practical consequence: pass the whole group and let emit sort it out. If you are notifying everyone in a department, pass every member including the person who triggered it. You do not need to filter them yourself.
If removing the actor leaves nobody, emit stops there and writes nothing.
3. It sorts out the school context
Every row in this system belongs to a school, and the database enforces that. Normally the current school is already known, because an HTTP request carries it.
But some callers are not HTTP requests — a background job, or Rover acting on a teacher's behalf. Those have no school attached to them. For those cases emit wraps its own work in withSchool(input.schoolId, …), which sets the school for the duration.
The practical consequence: emit works the same whether it is called from a controller or a background job. You always pass schoolId, and it is always used correctly.
The two tables
| Table | One row per | What it is for |
|---|---|---|
notification | recipient | The message itself. Holds the title, the body, the data, and whether it has been read. |
notification_delivery | recipient per outside channel | A record of trying to reach someone outside the app. Holds the channel, whether it worked, the address used, and any error. |
If a notification goes to three people by email, that is three notification rows and three notification_delivery rows.
The bell has no delivery row. This confuses people, so to be explicit: for in_app, the notification row is the delivery. It is already in the database, which is where the bell reads from. There is nothing separate to track. Delivery rows exist for messages that leave our system and might not arrive.
Two design decisions worth knowing
The type column is a plain varchar, not a database enum. A database enum would need a migration every time we add a notification type. A varchar does not. Adding a type is a code change only.
The channels list is copied onto each row. When a notification is created, the channels it used are saved onto the row itself, rather than looked up later.
Why: suppose today guardian.welcome goes to WhatsApp only. Next year someone adds the bell to it. If the bell read from the current matrix, every guardian welcome ever sent would suddenly appear in inboxes. Because each row remembers what it was sent with, history stays fixed.
Where the words come from
The title and body are written once, by a function called renderNotificationContent, and then shared by every channel:
[NotificationType.CLASS_TEACHER_ASSIGNED]: (d) => ({
title: 'Class teacher assignment',
body: `You are now the class teacher of ${d.className} ${d.streamName}.`,
}),The bell shows this. WhatsApp sends this. Email can override it with a proper template, but if it does not, this is what goes out.
Because one piece of text serves several channels, write it so it makes sense with nothing around it. "You are now the class teacher of S2 Blue" reads fine in a bell, in an email, and in a WhatsApp message. "Click here to see" does not.
Why one broken channel does not break the others
Each channel is dispatched inside its own separate try/catch.
If the email provider is down, the WhatsApp jobs are still queued, and the bell notifications have already been saved. The failure is contained to email.
There is a second protection too: all the delivery rows are written before any sending is attempted. So even if a channel fails completely, there is still a row recording that it was supposed to happen and what went wrong. A delivery is never invisible.
Where to go next
Sending a notification
Step by step: adding a new notification type, with every file you touch.
Channels
How each of the three channels finds a person and what can go wrong.
The inbox
How the bell reads notifications back and marks them read.
Notifications in the Hub
The Education Hub side — the bell, toasts, and links.
The Sync Flow
How a notification is authenticated, queued, and processed into a created, restored, or retired user — plus operating the pipeline and the gotchas.
Sending a notification
A step-by-step walkthrough of adding a new notification type — every file, what to write in it, and how to check each step worked.