0%
balqalam Logo
balqalam
FeaturesCommunityPricingDocumentation
Login
  • Introduction
  • Help center
  • Common questions
  • 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

Notifications

PreviousNext

Multi-channel delivery (in-app, email, push, SMS) with per-user preferences, RBAC, batch operations, and delivery tracking.

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

RouteRolesPurpose
/notificationsallNotification center with list and filters
/notifications/preferencesallPer-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 groupDEVELOPERADMINTEACHERACCOUNTANTSTAFFSTUDENTGUARDIAN
attendance_alertYYY———Y
absence_intention(_decision)Y—Y———Y
assignment_*Y————Y—
grade_posted, report_readyY————YY
fee_due / fee_paid / fee_overdueY——Y—YY
class_rescheduled / class_cancelledY—Y————
account_createdYY———Y—
system_alertYYYYYY—
announcement, event_reminderYYYYY—Y
message, message_mentionYYYYYYY

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)

GroupTypes (with default expiration)
Messagingmessage (30 d), message_mention (30 d)
Academicsassignment_created (60 d), assignment_due (7 d), assignment_graded (60 d), grade_posted (90 d)
Attendanceattendance_marked (30 d), attendance_alert (7 d), absence_intention (14 d), absence_intention_decision (14 d)
Financefee_due (30 d), fee_overdue (30 d), fee_paid (90 d)
Eventsannouncement (90 d), event_reminder (7 d), class_cancelled (7 d), class_rescheduled (7 d)
Systemsystem_alert (30 d), account_created (90 d), password_reset (1 d), login_alert (7 d), document_shared (60 d), report_ready (30 d)
PriorityUse case
lowInformational (document shared, report ready)
normalStandard (assignments, grades, attendance)
highRequires attention (fee due, attendance alert)
urgentImmediate 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

ActionDEVELOPERADMINTEACHER (own classes)ACCOUNTANT (fee types)STAFF (limited)STUDENTGUARDIAN
Read ownYYYYYYY
Mark as readYYYYYYY
CreateYall typesacademic onlyfee typesdoc / event——
Send batchYYown classesY———
Update preferencesYYYYYYY

Channels

ChannelEnabledProviderNotes
in_appyesinternalBell, center, DB-backed
emailyesResend SDKHTML with RTL (Rubik font), delivery logging
pushyesWeb PushVAPID (web-push); subscribe on /notifications/preferences; iOS needs the installed app
smsnoTwilioE.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:

ActionRolesPurpose
createNotification()staff, type-scopedCreate 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, acctBatch by role / class / users
updatePreferences()all (own only)Update per-channel preference
subscribe() / unsubscribe()allEntity 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 routeSchedulePurpose
/api/cron/process-email-notificationshourlyDrain pending email queue
/api/cron/process-broadcast-batchesevery 5 minFire scheduled / stuck broadcast batches
/api/cron/cleanup-notificationsdaily 2 AMDelete read+expired, anything > 90 days
/api/cron/event-remindersdaily 8 AMevent_reminder for events in next 24 h
/api/cron/assignment-remindersdaily 8 AMassignment_due
/api/cron/fee-overduedaily 9 AMfee_overdue
/api/cron/attendance-policiesdaily 1 AMattendance_alert to admins
/api/cron/teacher-remindershourly 8–16Reminder to teachers to mark attendance
/api/cron/scheduled-reportsevery 6 hreport_ready
/api/cron/publish-announcementsmidnightPublish scheduled announcements
/api/cron/expire-announcementsmidnightExpire 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 + toasts

See also

  • Messages — message + mention dispatch
  • Fees — fee_due / fee_paid / fee_overdue
  • Attendance — attendance_alert triggers
ClassroomsConference

On This Page

RoutesRole × type matrixNotification types (23)Recipient mapArchitectureRBACChannelsWeb Push (2026-09-12)PreferencesServer actions and queriesSchemaCron jobsUISee also

Built by Databayt ·

Welcome to balqalam.

A great journey is about to begin.