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):
| Field | Meaning |
|---|---|
userId | the recipient (one row per recipient; broadcast = many rows) |
portal | which portal renders it (SCHOOL, STARTUP, ADMIN, PARENT, STUDENT) |
tenantId | school scope when relevant |
kind | dotted event kind, e.g. lead.message, parent.request, vetting.decision, account.password_changed, system.notice |
title, body | short, plain language; body ≤ 140 characters when derived from user text |
href | where the item takes the user (inside the same portal) |
readAt | null = 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:
| Helper | Recipients |
|---|---|
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.
hrefmust 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=8on 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-unreadcarries 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 itshref(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
| Method | Path | Body / query | Returns |
|---|---|---|---|
| GET | /api/notifications | limit (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
- Mount
PortalShellwith the newportalkey — the bell appears automatically. - Add
<base>/notifications/page.tsxrendering<NotificationsPage base="…" />. - Extend the
Portalenum 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.
