Skoolsewa - School Management System Docs
Developer ResourcesArchitecture

Live Updates and Push

A client-side guide to the three mechanisms Skoolsewa clients use to learn about new events — the staff event stream, the personal notification stream, and FCM push — and when to use each.

Live Updates and Push

This page is for anyone building a client against this backend — the web admin panel today, or a future Android or iOS app. It does not repeat the endpoint-level contracts already written up elsewhere; it explains which mechanism to reach for and how the pieces fit together.

1. The three mechanisms, and which to use when

MechanismRouteTransportAudienceRequires the app open
Staff event streamGET /api/admin/realtime/streamServer-sent events (SSE)Staff with an admin session, filtered per subscriber by permissionYes
Personal notification streamGET /api/mobile/notifications/streamSSEAny authenticated user, scoped to their own arrivalsYes
Push (FCM)POST / DELETE /api/mobile/notification-devicesFirebase Cloud MessagingAny authenticated user, on a registered deviceNo

Full endpoint contracts: Realtime API for the staff stream; Notification API §8.14a, §8.18–8.19 for the personal stream and push device registration. This page does not duplicate those field-by-field references.

Rule of thumb. SSE is for a session that is actually open right now — a browser tab or a foregrounded app screen. It costs nothing to ignore, degrades to nothing when the connection drops, and both streams say so explicitly in their own code: every screen they enhance must remain correct on a plain refresh with the stream dead. Push is for everything that happens while the client is not open — a closed tab, a backgrounded or killed app — and it is the only one of the three that can reach a device with nothing running. History for everything is in Postgres: the staff stream's notification table and the personal stream's notification centre list are both durable and both queryable independently of whether any stream or push ever fired. Nothing that matters is ever only in Redis or only in a push payload — a client that missed every live signal can always catch up by reading the ordinary list and count endpoints.

Concretely:

  • Building an admin panel screen that shows live operational events to staff → open the staff event stream while that screen (or the shell around it) is mounted.
  • Building "my notifications" / a bell icon for any authenticated user → open the personal notification stream while the app is foregrounded, and poll /unread-count on load and after reconnects.
  • Making sure a person finds out about something while they are not looking at the app at all → register for push, and treat the SSE streams as a pure bonus on top of it.

2. Authentication

Mobile / native clients

A mobile or native client authenticates the ordinary way: POST /api/mobile/auth/login/email returns an accessToken and a refreshToken. Every subsequent request — including opening either SSE stream — sends Authorization: Bearer accessToken. There is no separate credential for streaming or for push; the same bearer token that authorizes GET /api/mobile/notifications authorizes GET /api/mobile/notifications/stream.

Browser clients — the BFF proxy

A browser's native EventSource object cannot set request headers, so it cannot send Authorization: Bearer. The admin panel (skoolsewa-frontend) does not work around this with a token in the URL — that would land the credential in access logs and in Referer headers on any request the page later makes. Instead its own Next.js server holds the session in an httpOnly cookie and proxies each stream through a route handler that opens the backend connection server-to-server.

Read directly (no changes made — both are read-only reference in this repo):

  • skoolsewa-frontend/app/api/admin/realtime/stream/route.ts
  • skoolsewa-frontend/app/api/admin/inbox/stream/route.ts

Both handlers follow the same shape:

  1. Read the bh_access_token httpOnly cookie server-side. Return 401 { errorCode: "AUTH_REQUIRED" } if it is missing — never open an unauthenticated upstream connection.
  2. Build an outbound Headers object with Authorization: Bearer accessToken and Accept: text/event-stream.
  3. For the staff stream only, forward the browser's own Last-Event-ID request header onto the upstream fetch, so a backend reconnect can replay what was missed. The personal-notification proxy does not forward Last-Event-ID — that stream has no id: on its frames and no replay to ask for (see §3).
  4. fetch() the real backend route (${API_URL}/api/admin/realtime/stream or ${API_URL}/api/mobile/notifications/stream) with signal: request.signal, so closing the browser tab aborts the request the proxy made to the backend — without this, every closed tab leaks one open backend connection forever.
  5. If the upstream response is not ok (a 401/403/503), return that status and body as-is, unwrapped, so the browser can tell "not allowed" or "unavailable" from "quiet." Both proxies are explicit that wrapping a denial as a dead stream would make an EventSource retry against a 401 forever, indistinguishable from a shop with nothing happening.
  6. Otherwise stream upstreamResponse.body straight through with text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, and X-Accel-Buffering: no — never buffered, never awaited to completion (it has none for the life of the tab).

A future non-browser client (a native app, a service that can set its own headers) does not need this proxy at all — it authenticates and opens the backend route directly, exactly as it would any other authenticated GET.

3. Reconnect behaviour, per stream

Both SSE endpoints reconnect automatically through the browser's or platform's EventSource implementation, honoring the retry: directive each stream sends once at connect. Beyond that, the two behave differently and a client must not assume one behaves like the other.

Staff event stream (/api/admin/realtime/stream)

  • Every frame that is not a heartbeat carries an id:. On disconnect, EventSource automatically sends Last-Event-ID set to the last id seen, on its next connection attempt.
  • The backend replays from a 500-event Redis history buffer (REALTIME_HISTORY_LIMIT), filtered to the caller's permissions, as missed frames — treat these as unread, since by definition the client has not seen them.
  • A first-ever connection (no Last-Event-ID sent) instead replays the most recent 200 events (REALTIME_COLD_START_LIMIT) as history frames — treat these as already read; they are context for an empty panel, not news.
  • retry: 3000 — the server states a 3-second reconnect delay explicitly, so a restarted API does not see every admin panel in the building reconnect in the same instant.
  • No per-user connection cap exists on this stream today.
  • A 503-shaped failure from the BFF proxy (REALTIME_UNREACHABLE) means the backend could not be reached at all — fall back to polling the screens the stream would have enhanced; none of them depend on the stream to be correct.

Personal notification stream (GET /api/mobile/notifications/stream)

Full contract: Notification API §8.14a.

  • Frames carry no id: at all, and there is no replay of any kind. A notification frame arrives once, live, and if the connection was not open when it fired, that arrival is not recoverable from the stream.
  • After any disconnect — including a normal EventSource auto-reconnect — refetch, do not assume a gap was filled: call GET /api/mobile/notifications (or just the page the badge belongs to) and GET /api/mobile/notifications/unread-count. Both are cheap, permission-scoped reads backed by Postgres, and are the only source of truth for "what did I miss."
  • Per-user cap of 5 concurrent streams (NOTIFICATION_STREAM_MAX_PER_USER). Opening a sixth evicts the oldest open connection on that user's channel, which receives event: evicted\ndata: {} immediately before its response ends.
  • On receiving evicted, the client must close that EventSource and must not auto-reconnect it. Reconnecting immediately re-hits the same cap and evicts a different connection — a loop that never settles. In practice this means: close the stream, then refetch the unread count and list once, and only open a fresh stream later (e.g. on the next natural navigation), not in an immediate retry loop.
  • retry: is sent the same way as the staff stream; treat a 503 the same way — fall back to polling, the notification centre and badge remain correct from Postgres regardless.

Summary table

Staff streamPersonal stream
Frame id:YesNo
Replay on reconnectYes — up to 500 events, permission-filteredNo
Cold-start behaviorLast 200 events, marked readNone — starts empty
Per-user connection capNone5, oldest evicted
On evictionN/Aevicted frame; do not auto-reconnect
What to do after any disconnectRely on replay; nothing extra neededRefetch list + unread count

4. FCM push setup

This section describes what a client must do to receive push. With PUSH_PROVIDER at its default of mock (see §5), a push send is recorded as delivered but nothing is sent to a device, so this is the contract a client implements against for when the provider is fcm.

Every platform shares the same backend contract: register a token with POST /api/mobile/notification-devices ({ token, platform }, platform one of android | ios | web), and remove it with DELETE /api/mobile/notification-devices/{publicId} on sign-out. Full field-by-field contract: Notification API §8.18–8.19.

Context7 (the MCP tool this guide would normally use to verify exact Firebase SDK call signatures) was not reachable in this session. Per this repository's documentation rules, the steps below are described in words rather than as code, with a link to the authoritative Firebase documentation for the exact API. Do not copy an SDK call signature out of this page from memory when implementing — read it from the linked docs.

Android / iOS (native)

  1. Integrate the Firebase SDK for the platform (FirebaseMessaging on Android via the Firebase Android SDK, or the Firebase iOS SDK's messaging component) following Firebase's own platform setup guide: Android client setup and iOS client setup.
  2. After a successful login (POST /api/mobile/auth/login/email), obtain the current FCM registration token from the platform SDK and call POST /api/mobile/notification-devices with { token, platform: "android" | "ios" }.
  3. Listen for the platform's token-refresh callback and re-register on every refresh — re-registering an unchanged token is a cheap no-op on the backend (it only touches last_used_at), so there is no need to track whether the token actually changed before calling the endpoint again.
  4. On sign-out, call DELETE /api/mobile/notification-devices/{publicId} for the device's own registration before clearing local session state. publicId is what the POST response returned when the token was registered — cache it locally alongside the token.
  5. When a push notification is tapped, open data.actionUrl from the notification payload — this is the field both platforms' handlers are documented (in the notification module's own docs) to read for the destination screen.

Web

The web panel is a browser context, which needs three additional pieces beyond a native app: a service worker, a VAPID key, and an explicit permission prompt. Follow Firebase's own web client setup guide for the exact calls, since this guide cannot verify SDK signatures without context7. In order:

  1. Firebase web config. Initialize the Firebase app in the browser with the project's web config object, per the Firebase web setup guide.
  2. A service worker that receives background messages. Register a dedicated service worker (conventionally firebase-messaging-sw.js) that initializes Firebase Messaging and handles background push delivery, per the receiving messages guide. This is what lets a push arrive while no tab has focus.
  3. The permission prompt. Before requesting a token, ask the browser's Notification permission explicitly (the browser's own Notification.requestPermission() API) — Firebase's getToken() call requires permission to already be granted, and requesting it silently on page load is poor practice; gate it behind a user action.
  4. Obtain a token with a VAPID key. Once permission is granted, request a token using the project's VAPID key and the registered service worker's registration, per the messaging client guide linked above. This token is what gets sent as token to the backend.
  5. Register with the backend, exactly as the native flow does: POST /api/mobile/notification-devices with { token, platform: "web" }. The backend documents that web registrations get FCM's webpush block with a TTL and, when the notification's action URL is https, fcmOptions.link — the field that decides what a click opens when every tab is closed, so nothing else needs to be done client-side to make a notification click open the right page.
  6. Re-register on load, not only once. A web push subscription/token can be invalidated by the browser independently of anything the backend does; re-run the getToken() step on app load whenever permission is already granted, and re-POST — the same cheap-no-op behavior as the native re-registration.
  7. On sign-out, call DELETE /api/mobile/notification-devices/{publicId} exactly as on native.

A web push client following this contract already exists, in the admin panel. skoolsewa-frontend (read-only reference from this repo) registers a service worker at /firebase-messaging-sw.js and mounts a component once per dashboard load that either re-registers the browser's current token — re-binding it to the session open at that moment — or, when notification permission is no longer granted, removes the stored device and clears its local record. On sign-out, the same removal runs before the logout request itself, ahead of the session-ending side effect described in the auth backend documentation that would otherwise retire the token anyway once the session ends.

5. Server-side prerequisites

PUSH_PROVIDER is a validated enum in apps/api/src/config/env.validation.ts (z.enum(["mock", "fcm"]).default("mock")), selected in apps/api/src/modules/notification/channels/notification-providers.module.ts. With mock, the API logs at boot that push messages are recorded and not sent, and deliveries are still marked sent, so a device never receives anything. With fcm, sends go to Firebase Cloud Messaging.

Turning this on requires:

  • Setting PUSH_PROVIDER=fcm in the API's environment.
  • Providing the Firebase service account, in either of the two forms env.validation.ts declares: FIREBASE_SERVICE_ACCOUNT_BASE64 (the whole service-account JSON, base64-encoded), or the three fields FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL and FIREBASE_PRIVATE_KEY. The project the credentials belong to must be the same project whose web or mobile app issued the device tokens.

Until both are true, every step in §4 above still functions end-to-end on the client (tokens register, the backend records them, sends are attempted) — the only missing piece is delivery to an actual device.

6. Current status

PieceStatus
GET /api/admin/realtime/stream (staff event stream)Built and live; consumed today by the admin panel via its BFF proxy at app/api/admin/realtime/stream/route.ts.
GET /api/mobile/notifications/stream (personal notification stream)Built and live; consumed today by the admin panel via its BFF proxy at app/api/admin/inbox/stream/route.ts.
POST / DELETE /api/mobile/notification-devicesBuilt and live as an API contract, and called by the admin panel's web push client (see below).
A phone/native appDoes not exist. /api/mobile is a path prefix describing an audience (guardian, pupil, teacher, parent), not a statement about devices — its only demonstrated client today is the web panel, which proxies /api/mobile/auth/* and the two SSE streams through its own Next.js server.
PUSH_PROVIDERDefaults to mock: a push send is recorded as delivered, but nothing is sent to a device. fcm sends through Firebase.

See Also

  • Realtime API — the staff event stream's full endpoint contract.
  • Realtime Backend — how an outbox event becomes a stream frame.
  • Notification API — the personal notification stream (§8.14a) and push device registration (§8.18–8.19).