- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
The notifications module delivers across four channels with per-user preferences, RBAC, batch operations, delivery tracking, and a polling fallback when Socket.IO is unavailable. Every domain dispatcher routes through dispatchNotification() so preference checking and expiration are uniform.
Routes
| Route | Roles | Purpose |
|---|---|---|
/notifications | all | Notification center with list and filters |
/notifications/preferences | all | Per-type per-channel toggle matrix |
Role × type matrix
Cross-tab of which roles receive which notification groups. Y = role receives that group; — = not dispatched. STUDENT defaults email: false (in-app only unless opted in); STAFF defaults email: false; DEVELOPER can receive anything (in-app by default).
| Notification group | DEVELOPER | ADMIN | TEACHER | ACCOUNTANT | STAFF | STUDENT | GUARDIAN |
|---|---|---|---|---|---|---|---|
attendance_alert | Y | Y | Y | — | — | — | Y |
absence_intention(_decision) | Y | — | Y | — | — | — | Y |
assignment_* | Y | — | — | — | — | Y | — |
grade_posted, report_ready | Y | — | — | — | — | Y | Y |
fee_due / fee_paid / fee_overdue | Y | — | — | Y | — | Y | Y |
class_rescheduled / class_cancelled | Y | — | Y | — | — | — | — |
account_created | Y | Y | — | — | — | Y | — |
system_alert | Y | Y | Y | Y | Y | Y | — |
announcement, event_reminder | Y | Y | Y | Y | Y | — | Y |
message, message_mention | Y | Y | Y | Y | Y | Y | Y |
Priority varies by event: most are normal; attendance_alert, assignment_due, fee_due, report_ready are high; fee_overdue and critical system_alert are urgent. SMS is reserved for attendance_alert to GUARDIAN. Email follows per-role defaults below.
Notification types (23)
| Group | Types (with default expiration) |
|---|---|
| Messaging | message (30 d), message_mention (30 d) |
| Academics | assignment_created (60 d), assignment_due (7 d), assignment_graded (60 d), grade_posted (90 d) |
| Attendance | attendance_marked (30 d), attendance_alert (7 d), absence_intention (14 d), absence_intention_decision (14 d) |
| Finance | fee_due (30 d), fee_overdue (30 d), fee_paid (90 d) |
| Events | announcement (90 d), event_reminder (7 d), class_cancelled (7 d), class_rescheduled (7 d) |
| System | system_alert (30 d), account_created (90 d), password_reset (1 d), login_alert (7 d), document_shared (60 d), report_ready (30 d) |
| Priority | Use case |
|---|---|
low | Informational (document shared, report ready) |
normal | Standard (assignments, grades, attendance) |
high | Requires attention (fee due, attendance alert) |
urgent | Immediate action (fee overdue, system alert) |
Recipient map
Source: src/components/school-dashboard/notifications/recipient-map.ts. Highlights:
attendance_alert— multiple trigger points (mark absent, threshold cron, excuse submit / decide, teacher unmarked cron). Recipients vary.grade_posted,report_ready— students + guardians.fee_due/fee_paid/fee_overdue— students + guardians; cron drives overdue.system_alert— catch-all for membership lifecycle, payroll, exam scheduling, substitutions, application status.account_created— onboarding to admins; membership approvals + enrollments to the user.message,message_mention— conversation participants and mentioned users.
A handful of types are defined but not yet dispatched: attendance_marked, password_reset, login_alert, document_shared.
Architecture
src/lib/dispatch-notification.ts is the single entry point.
// single user — used by domain dispatchers
await dispatchNotification({
userId,
schoolId,
type,
priority,
title,
body,
metadata,
actorId,
})
// bulk by audience — school / class / role scoped
await dispatchNotificationsToAudience({
schoolId,
type,
priority,
title,
body,
scope: { type: "school" }, // or { type: "grade" } or { type: "section" } or { type: "role", role }
})dispatchNotification() checks NotificationPreference, picks channels, sets expiresAt from config, then writes the row. dispatchNotificationsToAudience() filters by preference and uses createMany({ skipDuplicates: true }).
Domain dispatchers all call dispatchNotification(). No domain code touches db.notification.create directly.
Real-time delivery uses Socket.IO with rooms keyed by user:${userId}. The client singleton is src/lib/websocket/socket-service.ts. The Socket.IO server is external to this repo — without it, the client falls back to 30 s polling against GET /api/notifications/bell (a route handler on purpose: auth() rotates the session cookie inside server-action requests, so an action-based poll would ship a full RSC page re-render every 30 s; the route returns ~2 KB of JSON). Polling is visibility-aware — hidden tabs skip the round-trip and catch up when foregrounded — and concurrent hook instances share one in-flight request.
One hard-won gotcha: when no socket server is configured, socketService.connect() resolves without a socket. Connected-state must be read from socketService.isConnected() after the promise settles — trusting the resolution disabled the polling fallback and froze the production bell for three weeks (2026-07-19 → 2026-08-11).
RBAC
| Action | DEVELOPER | ADMIN | TEACHER (own classes) | ACCOUNTANT (fee types) | STAFF (limited) | STUDENT | GUARDIAN |
|---|---|---|---|---|---|---|---|
| Read own | Y | Y | Y | Y | Y | Y | Y |
| Mark as read | Y | Y | Y | Y | Y | Y | Y |
| Create | Y | all types | academic only | fee types | doc / event | — | — |
| Send batch | Y | Y | own classes | Y | — | — | — |
| Update preferences | Y | Y | Y | Y | Y | Y | Y |
Channels
| Channel | Enabled | Provider | Notes |
|---|---|---|---|
in_app | yes | internal | Bell, center, DB-backed |
email | yes | Resend SDK | HTML with RTL (Rubik font), delivery logging |
push | yes | Web Push | VAPID (web-push); subscribe on /notifications/preferences; iOS needs the installed app |
sms | no | Twilio | E.164 validation, +966 default; SMS_ENABLED=false |
Default channels: every role gets in_app; STUDENT and STAFF default email: false; all other roles default email: true. push and sms are off by default for everyone (provider keys missing).
Email delivery uses Resend, renders HTML + plain-text fallback, respects per-user quiet hours, batches via cron/process-email-notifications. Dev sends to delivered@resend.dev; production sends from noreply@school.databayt.org.
Web Push (2026-09-12)
A browser — or the installed PWA — becomes a recipient from the preferences page: the toggle asks permission on a tap, subscribes with the public VAPID key, and stores the subscription tenant-scoped (PushSubscription). process-push-notifications drains channels has push rows through src/lib/notifications/push-web.ts; the worker (public/service-worker.js) shows {title, body} and a tap opens the absolute url on the school's own host. Subscriptions the push service reports gone (404/410) are deleted. On iPhone, Web Push reaches only an app added to the Home Screen, so the toggle says "install first" in Safari. See Offline for the PWA shell.
Try it: on a phone (Android Chrome, or the installed app on iPhone) open Notifications → Preferences, tap the Push toggle and allow the browser's permission prompt — the row switches to "on" and a subscription row appears for this device. Have a teacher mark a child absent in quick attendance: within the next cron tick (every 15 minutes) the guardian's phone shows the absence as a system notification, and tapping it opens the notification in the school's app. Exam and live-class notices use the same channel.
Preferences
NotificationPreference is unique on [userId, type, channel]. Fields: enabled, quietHoursStart/quietHoursEnd (0–23), digestEnabled, digestFrequency (daily | weekly).
The preferences page renders one row per notification type × four channel columns plus quiet-hours and digest controls. Missing preference falls back to role defaults.
Server actions and queries
Actions in actions.ts:
| Action | Roles | Purpose |
|---|---|---|
createNotification() | staff, type-scoped | Create single |
markAsRead() | all (own only) | Mark single as read |
markAllAsRead() | all (own only) | Mark all as read |
deleteNotification() | all (own only) | Delete |
createBatch() | admin, teacher, acct | Batch by role / class / users |
updatePreferences() | all (own only) | Update per-channel preference |
subscribe() / unsubscribe() | all | Entity subscriptions |
Queries (queries.ts): getNotifications (paginated), getNotificationsCursor (infinite scroll), getUnreadCount, getNotificationStats, getRecentNotifications, getByType, getExpired, getPreferences, getSubscriptions, getPendingEmailQueue.
Schema
model Notification {
id String @id @default(cuid())
schoolId String
userId String
type NotificationType
priority NotificationPriority @default(normal)
title String
body String @db.Text
metadata Json?
actorId String?
read Boolean @default(false)
readAt DateTime?
channels NotificationChannel[]
expiresAt DateTime?
emailSent Boolean? emailSentAt DateTime? emailError String?
pushSent Boolean? pushSentAt DateTime? pushError String?
smsSent Boolean? smsSentAt DateTime? smsError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([schoolId, userId, read, createdAt])
@@index([schoolId, type])
@@index([schoolId, createdAt])
@@index([schoolId, emailSent])
@@index([schoolId, pushSent])
@@index([schoolId, smsSent])
@@index([expiresAt])
}Supporting models: NotificationBatch is fully wired — sendBroadcast (school/communication) creates one per broadcast, processNotificationBatch fans it out, and /api/cron/process-broadcast-batches (every 5 min, added 2026-06-12) sweeps scheduled batches whose scheduledFor has arrived plus any stuck pending batch past a 10-minute grace (previously a scheduled broadcast stayed pending forever). NotificationTemplate, NotificationSubscription, NotificationSummary remain schema only. Source: prisma/models/notifications.prisma.
Enums: NotificationType, NotificationPriority, NotificationChannel.
Cron jobs
| Cron route | Schedule | Purpose |
|---|---|---|
/api/cron/process-email-notifications | hourly | Drain pending email queue |
/api/cron/process-broadcast-batches | every 5 min | Fire scheduled / stuck broadcast batches |
/api/cron/cleanup-notifications | daily 2 AM | Delete read+expired, anything > 90 days |
/api/cron/event-reminders | daily 8 AM | event_reminder for events in next 24 h |
/api/cron/assignment-reminders | daily 8 AM | assignment_due |
/api/cron/fee-overdue | daily 9 AM | fee_overdue |
/api/cron/attendance-policies | daily 1 AM | attendance_alert to admins |
/api/cron/teacher-reminders | hourly 8–16 | Reminder to teachers to mark attendance |
/api/cron/scheduled-reports | every 6 h | report_ready |
/api/cron/publish-announcements | midnight | Publish scheduled announcements |
/api/cron/expire-announcements | midnight | Expire old announcements |
UI
bell-icon.tsx (badge + recent popover), card.tsx (per-item rendering), list.tsx (tabbed all/unread, date grouping), notification-center-client.tsx (SSR + socket merge), and preferences-form.tsx (per-type per-channel toggle matrix).
Hooks expose the data — dual-channel (Socket.IO when available, 30 s polling fallback), with optimistic updates. An initial fetch always runs regardless of transport (a live socket only pushes new events), and polled snapshots merge through poll-merge.ts — forward-only read-state sync, so a notification read in another tab clears here without fighting optimistic updates.
Every hook in a tab shares one bell request. A success is reused for 5 s. A failed or non-OK request starts a 15 s cooldown for all of them, and the shared fetch resolves null instead of rejecting, so a first load while offline no longer throws an unhandled rejection. Before the cooldown (2026-09-13), an unreachable server drew bursts of about 120 bell requests: a failure is instant, so each remount, focus and poll became its own request.
The hooks, in use:
const {
isConnected,
unreadCount,
recentNotifications,
markAsRead,
markAllAsRead,
} = useNotifications({
autoConnect: true,
showToast: true,
pollInterval: 30000,
})
useNotificationBell() // count + 5 recent, no toast
useNotificationCenter() // all recent + toastsSee also
- Messages — message + mention dispatch
- Fees —
fee_due/fee_paid/fee_overdue - Attendance —
attendance_alerttriggers