System logic

In-app notifications — standard

In-app notifications — standard

One notification model, one bell, one dropdown and one page, shared by every portal (platform admin, school, vendor, parent, pupil). A new portal gets all of it by mounting PortalShell and adding one route file. Last updated 2026-09-03.

1. Model

Notification (prisma/schema.prisma):

FieldMeaning
userIdthe recipient (one row per recipient; broadcast = many rows)
portalwhich portal renders it (SCHOOL, STARTUP, ADMIN, PARENT, STUDENT)
tenantIdschool scope when relevant
kinddotted event kind, e.g. lead.message, parent.request, vetting.decision, account.password_changed, system.notice
title, bodyshort, plain language; body ≤ 140 characters when derived from user text
hrefwhere the item takes the user (inside the same portal)
readAtnull = unread

Kinds are grouped by their first segment for the type filter (KIND_GROUPS in lib/notifications.ts): Pipeline, Challenges, Parent questions, Vetting, Account & access, Audit & privacy, System. Title and body may name people, so the row carries the PII flag columns and is captured by the audit harness like any other write.

2. Producing

Call the helpers in lib/notifications.ts from the server action that performs the change, after the write succeeds and inside the same request:

HelperRecipients
notify(input)one user
notifyMany(userIds, input)a set of users (de-duplicated)
notifySchool(schoolId, input, roles?)active members of a school with the given roles (default admins)
notifyStartup(startupId, input)the vendor's login user
notifyAdmins(input)every platform admin

Rules for producers:

  • Notify the other side of an interaction, never the actor (a school that sends a message is not told it sent a message).
  • One notification per event per recipient; do not fan out per field changed.
  • href must be a portal path the recipient can open; never a link into another portal.
  • Body text derived from user input is truncated to 140 characters and never includes another tenant's data.
  • Security events (password changed, access granted) always notify the affected account.

Events wired today: lead opened and lead messages (both directions), challenge responses, parent questions to the school and answers back to the guardian, staff access granted, vetting decisions, password changes; platform-admin actions on a tenant's behalf (plan changes, credit adjustments, password resets, listing changes, API key revoked); support access changed and support sessions started / ended.

3. The bell (components/shell/notification-bell.tsx)

  • Lives in the utility bar at the top-right of every portal page.
  • Polls GET /api/notifications?limit=8 on mount, on every navigation, on window focus and every 60 seconds.
  • Unread state: brand-tinted bell with a count badge (99+ cap) and aria-label="Notifications, N unread"; data-unread carries the count for tests.
  • Dropdown (role="dialog"): header with *Mark all read*, up to 8 most recent items (unread: bold title, brand dot, tinted row), each showing title, two-line body, group and relative time. Clicking an item marks it read and navigates to its href (or the notifications page when it has none). Footer: *View all notifications*. Closes on outside click or Esc.

4. The page (components/shell/notifications-page.tsx, at <portal>/notifications)

  • Filters (GET form, shareable URL): free-text search over title and body, status (all / unread / read), type (kind group), from and to dates. Results count, *Clear*.
  • List: unread rows tinted with a dot; title links to href; group badge; created and read timestamps; per-row *Mark as read*; page-level *Mark all read*. 25 per page with Previous / Next.
  • Everything is scoped to the signed-in user; the API and actions refuse other users' rows.

5. API

MethodPathBody / queryReturns
GET/api/notificationslimit (1–25){ unread, items[] }
POST/api/notifications{ ids: [] } or { all: true }{ ok, marked, unread }

Session-cookie authenticated. Server actions markNotificationRead and markAllNotificationsRead back the page's forms.

6. Retention

Read notifications older than 90 days are removed by trimNotifications (schedule alongside the audit outbox trim). Unread ones are kept.

7. Adding a portal

  1. Mount PortalShell with the new portal key — the bell appears automatically.
  2. Add <base>/notifications/page.tsx rendering <NotificationsPage base="…" />.
  3. Extend the Portal enum and produce notifications with the helpers above.

8. Open items

  • Email or push delivery for unread items older than a day (digest), with per-user opt-out.
  • Per-kind preferences on the account page.
  • Live updates (server-sent events) instead of 60-second polling.

Source: docs/notifications.md in the repository. Last updated with the code it describes.

Notifications — EduSpaze