- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- 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
- Playwright
- Prettier
- Block Rebound
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: "class", classId } 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 | no | Web Push | Service worker exists; no VAPID keys |
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.
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:
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