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.
This page walks through adding one new notification from nothing. Read Notifications first if you have not — it explains the words used here.
Before you start, know that a notification type lives in two repositories: Naalya-API for almost everything, and Education-Hub for one small file at the end.
What we are building
We will add a notification for: a report card was returned to a teacher for changes.
- It should tell the teacher whose report was returned.
- Through the in-app bell and email.
- The person clicking it should land on that report.
The seven steps
| Step | File | Repo | What happens if you skip it |
|---|---|---|---|
| 1 | notification.types.ts | API | Nothing compiles — there is no type |
| 2 | notification-channels.ts | API | Build fails |
| 3 | notification-content.ts (CONTENT) | API | Build fails |
| 4 | notification-content.ts (EMAILS) | API | Builds fine. Email silently never sends. |
| 5 | notification-link.ts | Hub | Builds fine. Notification has no clickable link. |
| 6 | your service — working out who gets it | API | Builds fine. The wrong people, or nobody, is told. |
| 7 | your service — the emit call | API | Builds fine. Nothing is ever sent. |
Steps 2 and 3 are safe — the compiler stops you if you forget them. Steps 4, 5, 6 and 7 all fail silently, which is why this page spells out how to check each one worked.
Step 1: Name the type and its data
Open libs/shared/src/notification/notification.types.ts.
1a. Add the type to the enum:
export enum NotificationType {
GUARDIAN_WELCOME = 'guardian.welcome',
SCHEME_GROUP_INVITE = 'scheme_group.invite',
CLASS_TEACHER_ASSIGNED = 'class_teacher.assigned',
ACADEMIC_REPORT_RETURNED = 'academic_report.returned',
}The string value follows a pattern: resource.verb. The resource is the thing that changed (academic_report), written in snake_case. The verb is what happened to it (returned), in the present tense.
1b. Say what data it carries:
export type NotificationDataMap = {
// …existing entries
[NotificationType.ACADEMIC_REPORT_RETURNED]: {
campusId: string;
reportId: string;
studentName: string;
returnedByName: string;
};
};How do you decide what goes in here? Ask two questions:
- What words does the message need? Ours says "Sarah returned Amina's report", so we need
studentNameandreturnedByName. - What does the Hub need to link to it? Every Hub page for a report lives under a campus, so we need
campusIdandreportId.
Include those, and nothing else. This gets stored as JSON in the database, so it must be JSON-safe — strings, numbers, booleans. No Date objects, no class instances.
1c. Publish it to the Education Hub. These types are shared with the Hub over the type bus. Run:
task busSkip this and step 5 will not compile, because the Hub will not know your new type exists. See How Chowbea works.
Step 2: Choose the channels
Open apps/server/src/app/notification/notification-channels.ts and add one line:
[NotificationType.ACADEMIC_REPORT_RETURNED]: [IN_APP, EMAIL], How to choose. This is decided by who you are sending to, not by preference:
- Staff (teachers, admins) →
[IN_APP, EMAIL]. They log into the Hub and have school email addresses. - Guardians (parents) →
[WHATSAPP]. They do not use the Hub, and the system only has phone numbers for them.
Do not mix the two. WhatsApp can only find a phone number for a guardian, and guardians have no bell to look at. Channels explains exactly why.
A report goes to a teacher, so: bell and email.
You cannot forget this step — the matrix is a Record covering every type, so the build fails until you add your line.
Step 3: Write the message
Open apps/server/src/app/notification/notification-content.ts and find the CONTENT object:
[NotificationType.ACADEMIC_REPORT_RETURNED]: (d) => ({
title: 'Report returned',
body: `${d.returnedByName} returned ${d.studentName}'s report for changes.`,
}), d is the data you defined in step 1, fully typed — your editor will autocomplete d.studentName.
Write it so it stands alone. This exact text appears in the bell, and is sent as the WhatsApp message for WhatsApp types. There is no surrounding page to give it context. "Sarah returned Amina's report for changes" works anywhere. "Your report needs attention — click below" does not.
You cannot forget this step either — CONTENT must cover every type, so the build fails without it.
Step 4: Add the email template
Only needed because we put EMAIL in the matrix in step 2. Same file, different object — EMAILS:
[NotificationType.ACADEMIC_REPORT_RETURNED]: (d, to, frontendUrl) => ({
type: EmailType.ACADEMIC_REPORT_RETURNED,
to,
subject: 'A report needs your attention',
data: {
studentName: d.studentName,
reportUrl: `${frontendUrl}/staff-hub/campus/${d.campusId}/reports/${d.reportId}`,
},
}), Three arguments arrive: d is your data, to is the recipient's email address (already looked up), and frontendUrl is the Hub's base URL from config, with any trailing slash removed.
This returns a normal email payload, so the type also needs an EmailType member and a React Email template — see Sending Emails.
This step fails silently — here is how to catch it
EMAILS is optional per type, so nothing breaks at build time. If you skip it, the notification is created, the bell works, and the email never sends.
There is no error in the log. The only trace is in the database:
SELECT channel, status, error FROM notification_delivery
ORDER BY created_at DESC LIMIT 5;A row with status = 'failed' and error = 'NO_EMAIL_TEMPLATE' means you are missing this step.
Note that the email builds its own link from frontendUrl. This is the only place the server constructs a Hub URL, and only because an email has no app to ask.
Step 5: Make it clickable in the Hub
Switch to the Education-Hub repository. Open src/lib/notifications/notification-link.ts and add a case:
case NotificationType.ACADEMIC_REPORT_RETURNED: {
const reportId = asId(data?.reportId)
return reportId
? {
label: 'Open report',
to: '/staff-hub/campus/$campusId/reports/$reportId',
params: { campusId, reportId },
}
: null
} Why the asId check. The data arriving here is loosely typed — it comes over a websocket as a plain object. asId returns the value only if it is a non-empty string, otherwise null. If an id is missing you return null, which means "no link" rather than building a broken route.
Why the route is here and not on the server. The server stores campusId and reportId in data, never a URL. If it stored /staff-hub/campus/x/reports/y and someone later restructured the Hub's routes, every stored URL would break, and the API would have no way to know. Keeping routes in the Hub means the router's own types catch a rename.
This step also fails silently
Skip it and there is no error. The notification appears in the bell and a toast pops up — but neither has a working link, because the function falls through to its default and returns null.
To check: trigger the notification and confirm the toast has an Open report button.
Step 6: Work out who receives it
This step is entirely yours. The notification system does not decide who gets a notification — there is no subscription table, no per-user preferences, no opt-out list, and no rule that says "assigned teachers get told about assignments".
You work out a list of user ids and hand it over. Whoever is in that list is told. That is the whole mechanism.
emit does exactly two things to the list you give it, and nothing else:
- Removes duplicates.
- Removes
actorUserId, so the person who caused the event is not told about their own action.
First: user ids, not profile ids
This is the mistake that costs people an afternoon, so it comes before the patterns.
A person in this system is stored across two tables:
| Table | What it holds | Its id |
|---|---|---|
user | The account — email, password, which type of user they are | The user id |
staff_profile, guardian_profile, student_profile | The person's details for that role — name, phone, employee number | A profile id, plus a userId column pointing back to the account |
So one teacher has two ids: a user id and a staff profile id. They are different values.
recipientUserIds needs the user id. But most of the tables you will be querying store the profile id, because that is what a teaching assignment or an enrollment refers to.
For example, subject_x_teacher has a staffId column. That is a staff profile id. Passing it straight to emit looks completely reasonable and is wrong:
// WRONG — staffId is a staff profile id, not a user id
recipientUserIds: rows.map((r) => r.staffId)Nothing throws. No error is logged. A notification row is written against an id that belongs to no user account, so nobody ever sees it. The bell stays empty and there is nothing obviously broken to find.
To convert, load the profiles and read their userId:
const staff = await this.staffProfileRepository.findAll({
where: { id: In(staffIds) },
});
const userIdByStaff = new Map(staff.map((s) => [s.id, s.userId]));
// now: userIdByStaff.get(someStaffId) is a real user idHow to check you got this right
After triggering your notification, run this. If it returns no rows, you sent profile ids.
SELECT n.id, n.recipient_user_id, u.email
FROM notification n
LEFT JOIN "user" u ON u.id = n.recipient_user_id
ORDER BY n.created_at DESC LIMIT 5;A notification row whose email column is NULL is one addressed to an id that is not a user.
The three patterns
Every notification in the codebase today builds its list in one of three ways.
Pattern 1 — the person the event happened to.
The simplest case. guardian.welcome is sent to the guardian whose account was just created, and you already have that user in hand:
recipientUserIds: [user.id],No actorUserId is passed here at all. The admin who created the account is not part of the guardian's story, and the guardian could never be the actor anyway, so there is nothing to filter out.
Pattern 2 — one person, found from a related record.
class_teacher.assigned tells the teacher who was just assigned. The assignment stores a staff id, so the service loads that profile first purely to get the user id:
const staff = await this.staffProfileRepository.findById(staffId);
if (!staff || !schoolId) return;
await this.notificationService.emit({
type: NotificationType.CLASS_TEACHER_ASSIGNED,
schoolId,
recipientUserIds: [staff.userId], // the profile's userId, not staffId
actorUserId: assignedBy,
// …
});Note the if (!staff) return. If the profile cannot be loaded there is no user id to send to, so the notification is abandoned rather than sent to a broken id.
Pattern 3 — a group found by querying.
scheme_group.invite has to tell every teacher who teaches that subject on those streams. Nobody hands you that list, so you query for it:
// 1. Who teaches this subject on these streams?
const rows = await this.subjectXTeacherRepository.findAll({
where: { subjectId: scheme.subjectId, streamId: In(streamIds) },
});
if (rows.length === 0) return;
// 2. Load those staff profiles so we can get their user ids
const staff = await this.staffProfileRepository.findAll({
where: { id: In([...new Set(rows.map((r) => r.staffId))]) },
});
const userIdByStaff = new Map(staff.map((s) => [s.id, s.userId]));The new Set there is because one teacher can teach the same subject on several streams, which would load their profile several times.
Send one notification per wording
Pattern 3 has a second lesson. Look at what the message says:
"Sarah invited you to plan Physics for S2 Blue together."
The stream name is in the text. So teachers on S2 Blue and teachers on S2 Green need different messages — which means separate emit calls, not one call with everyone in it:
// One emit per stream: the stream name is part of the message.
for (const streamId of streamIds) {
const recipientUserIds = rows
.filter((r) => r.streamId === streamId)
.map((r) => userIdByStaff.get(r.staffId))
.filter((id): id is string => Boolean(id));
if (recipientUserIds.length === 0) continue;
await this.notificationService.emit({ /* …this stream's data… */ });
}That .filter((id): id is string => Boolean(id)) drops anyone whose profile did not load — Map.get returns undefined for a missing key, and an undefined in the array would become a broken recipient.
The rule: if the message text differs between groups of people, emit once per group. If everyone gets identical wording, one call with everyone in it is right.
When to pass actorUserId
| Situation | Pass it? |
|---|---|
| A person acted, and might be in the recipient list | Yes. They are removed automatically. |
| A person acted, and cannot be in the list | Optional — harmless either way |
| The system acted, with no person behind it | Leave it out |
You never need to filter the actor out yourself. Passing the whole group and letting emit remove them is the intended style.
If the list ends up empty
If your list is empty — or becomes empty after the actor is removed — emit stops immediately and writes nothing. No rows, no error.
This is normal and expected. The scheme-group code relies on it: if (rows.length === 0) return handles the case where a subject has no teachers assigned yet.
Step 7: Send it
Back in the API. Inject NotificationService into your service, and call emit after the database write has finished:
await this.reportRepository.save(report); // the real work, now done
await this.notificationService.emit({
type: NotificationType.ACADEMIC_REPORT_RETURNED,
schoolId,
recipientUserIds: [report.teacherUserId],
actorUserId: caller.sub,
data: {
campusId: report.campusId,
reportId: report.id,
studentName: report.student.name,
returnedByName: caller.name,
},
});Four things to notice:
- No
try/catch.emitnever throws. Wrapping it does nothing. awaitit, but do not worry. Awaiting keeps the work inside the request. It can never turn a successful save into a failed response.actorUserIdis the person who did it. Covered in step 6 —emitremoves them from the recipients for you.- After the write, not inside it. If you call
emitinside a transaction callback, it runs before the transaction commits — and still runs if the transaction is rolled back. You would notify someone about something that never happened.
Checking it worked
Trigger the action, then work down this list:
1. Was a notification created?
SELECT id, type, title, channels FROM notification ORDER BY created_at DESC LIMIT 5;No row means emit was never reached, or every recipient was filtered out as the actor.
2. Did the bell update? Open the Hub as the recipient. The badge should increase and a toast should appear if they were online.
3. Did the email go?
SELECT channel, status, address, error FROM notification_delivery
ORDER BY created_at DESC LIMIT 5;status = 'sent' is good. error = 'NO_EMAIL_TEMPLATE' means step 4 is missing. error = 'NO_ADDRESS' means the recipient has no email address on their user record.
4. Does the link work? Click the toast's action button and confirm it lands on the right page.
Checklist
-
NotificationTypemember added -
NotificationDataMapentry added -
task busrun in the API repo -
NOTIFICATION_CHANNELSentry added -
CONTENTbuilder written, and it reads well on its own -
EMAILSbuilder written (only ifEMAILis in the matrix) -
EmailTypemember and template added (only if using email) -
notificationLinkcase added in the Hub (only ifIN_APPis in the matrix) - Recipient list built from user ids, not profile ids
- One
emitper group if the message wording differs between them -
emitcalled after the write, withactorUserIdpassed -
notification_deliverychecked forfailedrows