- 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 messages module powers in-platform communication: 5 conversation types, file attachments, emoji reactions, read receipts, full-text search, full RBAC across 8 roles. Messages dual-deliver to WhatsApp when the school has an active session, with bidirectional bridging for incoming replies.
Source directory is
messaging/, notmessages/. All component code lives insrc/components/school-dashboard/messaging/(~13k LOC, 80 files). The route, however, is/messages.
Routes
The messaging UI lives in its own full-screen route group, (school-messaging), with no dashboard header/sidebar/footer.
| Route | Purpose |
|---|---|
/messages | Sidebar + chat interface |
/messages?conversation=X | Deep link to a specific conversation |
File-system path: src/app/[lang]/s/[subdomain]/(school-messaging)/messages/page.tsx.
Conversation types
| Type | Purpose | Auto-participants | Key fields |
|---|---|---|---|
direct | 1-to-1 private | both users | directParticipant1Id, 2Id |
group | Multi-user group | creator as owner | title, description, avatar |
class | Label only (no classId) | — | — |
department | Department-wide channel | department members | departmentId |
announcement | Admin / DEVELOPER broadcast | all school members | title, read-only for non-admins |
Creatable today: only
directandgroup. The create dialog (new-conversation-dialog.tsx) exposes just those two tabs.class,department, andannouncementexist in theConversationTypeenum and the RBAC matrix below, but have no creation entry point yet — treat them as schema-level scaffolding, not shipped features.
Direct conversations dedup on directParticipant1Id / directParticipant2Id — getOrCreateDirectConversation() checks both orderings. (Dedup is enforced in application logic only; there is no DB unique constraint, so concurrent creates can still race a duplicate.)
System messages auto-generate for lifecycle events: participant_joined, participant_left, participant_added, participant_removed, conversation_created, conversation_renamed, conversation_archived, message_pinned, message_unpinned.
RBAC
The matrices below reflect the authorization layer + config intent. Only
directandgrouphave a creation UI today (see note above), so theclass/department/announcementcolumns describe permission scaffolding, not reachable flows.
Platform role × conversation type
| Role | Direct | Group | Class | Department | Announcement |
|---|---|---|---|---|---|
| DEVELOPER | Y | Y | Y | Y | Y |
| ADMIN | Y | Y | Y | Y | Y |
| TEACHER | Y | Y | Y (own) | Y | — |
| ACCOUNTANT | Y | Y | — | — | — |
| STAFF | Y | Y | — | — | — |
| STUDENT | Y | Y (create) | — | — | — |
| GUARDIAN | Y | — | — | — | — |
The class type remains only as a label: classes were removed on 2026-10-04 and conversations carry no classId.
Participant role within a conversation
| Role | Send | Edit own | Delete own | Add | Remove | Pin | Manage |
|---|---|---|---|---|---|---|---|
| owner | Y | Y | Y | Y | Y | Y | Y |
| admin | Y | Y | Y | Y | Y | Y | Y |
| member | Y | Y | Y | groups | — | Y | — |
| read_only | — | — | — | — | — | — | — |
| guest | — | — | — | — | — | — | — |
Message lifecycle
| Status | Meaning |
|---|---|
sending | Client-side optimistic |
sent | Server received and persisted |
delivered | Delivered to recipient's client |
read | Recipient has viewed |
failed | Send failed (validation, network, permissions) |
| Content type | Rendering |
|---|---|
text | Plain text with link detection |
image | Inline preview with lightbox |
file | File card with download |
system | Centred, muted styling |
Messages soft-delete via isDeleted: true + deletedAt ("This message was deleted."). Only sender (or owner / admin) can delete. Edits set isEdited: true + editedAt; UI shows "edited".
File attachments
Drag-and-drop with previews and progress bars. S3 / Vercel Blob storage. Path: messaging/{schoolId}/{conversationId}/{filename}.
Real-time delivery
Live updates use Socket.IO via a standalone server in socket-server/ — 15 events (message:new/updated/deleted/read/reaction, conversation:updated/new, conversation:participant_added/removed, typing:start/stop). Presence is Redis-backed (green dots on cards, last-seen in header). Server→client pushes go through POST /api/emit and /api/emit-to-users, protected by an x-emit-secret header.
Ops status: the Socket.IO server is not yet deployed — the Oracle Cloud plan in #262 was never provisioned and has no successor yet. Until it ships, the client falls back to polling. Realtime works end-to-end once the server is live and
NEXT_PUBLIC_SOCKET_URL/SOCKET_SECRET/EMIT_SECRETare set in production.
The messaging page opens the socket itself when NEXT_PUBLIC_SOCKET_URL is set
(the dashboard header that used to be the only caller is not rendered on this
route). The open thread's sync — the socket room, the polling fallback and
mark-as-read — lives in one hook, hooks/use-thread-sync.ts, and serves the
phone and the desktop alike. Polling is deliberate about cost: the open thread
is polled every 5 s while the tab is visible and the thread active, every 12 s
after two quiet minutes, and not at all while the tab is hidden, with an
immediate catch-up when it comes back or the network returns. The conversation
list polls every 15 s for what changed since the last tick plus a fresh
unread map, and refetches everything only on every eighth tick. Without a
socket server a sent message shows a single tick until it is read; the double
"delivered" tick can only come from the server's message:delivered.
WhatsApp integration
When the school has an active WhatsApp session, in-app messages auto-dispatch via Evolution API. Bidirectional — incoming replies bridge back via webhook.
Ops status (2026-09-13): Evolution API runs inside the balqalam Cloudflare container as a second process (loopback
127.0.0.1:8080, never exposed, supervised bycf/entry.cjs, state in the Neon databaseevolution). It costs nothing on top of the container already paid for. Every hogwarts deploy restarts the bridge, which reconnects from its saved session. The previous Railway instance died with its trial, so each school must pair its phone again: Dashboard → WhatsApp → Connect → scan the QR.
Pairing and using it
- Pair the school's number (admin): Dashboard → WhatsApp → Connect WhatsApp. A QR appears within seconds; on the school's phone open WhatsApp → Settings → Linked Devices → Link a Device and scan it. The status card flips to Connected and stays paired across app deploys and restarts (the session is stored server-side). The bridge was rebuilt on 2026-09-13, so every school that was paired before must pair again once.
- Send from a chat. Open any conversation whose participant has a phone on file, switch the W toggle in the chat header on, and send as usual. The bubble shows the WhatsApp delivery ticks; the recipient sees the message from the school's number.
- Replies come back. A reply on WhatsApp lands in the same conversation in the app within a few seconds (the Evolution webhook), attributed to the sender's phone.
- Groups work the same way from the Groups tab: create a WhatsApp group from a class or section and broadcast to it.
Phone resolution: Guardian.guardianPhoneNumbers → Teacher.teacherPhoneNumbers → StaffMember.staffPhoneNumbers. Users without a domain record send in-app only.
| Element | Location | Purpose |
|---|---|---|
| W toggle | Chat header | Enable / disable WhatsApp per conversation |
| W badge | Conversation card | Marks WhatsApp-enabled conversations |
| Status icon | Message bubble | WhatsApp delivery status (sent / delivered / read / failed) |
Rate limits: 1 msg / sec / school, 500 direct / day / school. Failed dispatches retry with exponential backoff (max 5 attempts) via cron/process-whatsapp-notifications (every 5 minutes).
Ops caveats: the retry cron requires
CRON_SECRET(set in production since the Cloudflare cutover), and the webhook fails closed unlessWHATSAPP_WEBHOOK_SECRETis set — the per-instance webhook URL carries?secret=automatically, built fromWHATSAPP_WEBHOOK_BASE_URL(balqalam.com) since 2026-09-13, so a school connected before that date must reconnect once for the new URL and secret to be registered on its instance. Group WhatsApp is fully supported: the scalarwhatsapp*columns onMessagemirror 1:1 state, while groups write oneMessageWhatsappDeliveryrow per recipient — retried independently with backoff, and advanced to delivered/read by webhook status events. Phones are normalized to a single digits-only form (strips+/00) across outbound, storage, and inbound sender lookup.
Key files: whatsapp-bridge.ts, src/lib/whatsapp/evolution-client.ts, src/app/api/webhooks/whatsapp/route.ts, src/app/api/cron/process-whatsapp-notifications/route.ts, src/components/school-dashboard/whatsapp/.
Test accounts (pnpm db:seed:single messaging-phones): admin@balqalam.com (00966557721603), accountant@balqalam.com (00966504559207).
Contacts sidebar
WhatsApp-style contact list grouped by role (contacts/). Click → createConversation({ type: "direct", participantIds: [contactId] }); existing direct conversations are reused via dedup (app check + a DB unique index on the direct pair, so concurrent creates can't race a duplicate). Names translate on demand via one batched getNames() call. The WhatsApp connect dialog (QR) is also embedded here, alongside the per-chat W toggle.
On a phone
Below md the block drops its split-pane and becomes the iOS WhatsApp app: the
conversation list (mobile/ios-chat-list.tsx), then the conversation itself
(mobile/chat/messages-view.tsx) once a row is tapped. Bubbles carry the WhatsApp
grammar — a tail on the last message of each same-sender run, the timestamp and
ticks set into the bubble, a pill at each day boundary, sender names on incoming
group messages — and the whole thing mirrors under Arabic. mobile/chat/adapt.ts
is the one place that turns a MessageDTO into what the view draws.
Sending feels the way it does in the app: the bubble is on screen before the request leaves (with a small clock, then a single tick), the field is already clear and still focused, and a message the server refused shows a red mark and resends on tap — or on its own when the connection returns. The composer grows to about five lines, keeps the keyboard up when send, camera or mic are tapped, remembers a draft per conversation, and sizes the whole view to the visual viewport while the keyboard is up so the composer sits on the keys and the last message stays just above them. Return on a touch keyboard inserts a line, as in the iPhone app; Enter on a hardware keyboard sends, as on WhatsApp Desktop.
The thread scrolls like the app's. It opens at the newest message and stays pinned there through image decodes and keyboard resizes; a reader who has scrolled up into history is not pulled back down when something arrives — a round button appears with a count instead; older pages are requested about a screen and a half before the top, come in behind a spinner pill, and land without moving what the reader was looking at. Each message takes its own text direction, so an Arabic message is right-aligned inside the English UI and an English one left-aligned inside the Arabic UI.
The phone view reads and sends. Attachments, editing, deleting, reactions, replies, forwarding and voice recording remain desktop-only.
One design, both widths — one tree per width
The desktop conversation follows the same WhatsApp grammar as the phone, sharing
the atoms rather than restating them. Only one of the two trees is ever mounted:
the server guesses the width from the request's client hints, the client's first
render repeats the guess so hydration matches, and matchMedia corrects it a
frame later. Each tree is its own chunk, so a phone never downloads the
split-pane and never runs its effects. ChatInterface keeps every desktop-only
capability — replies, edits, reactions, attachments, typing — while the outbox
(optimistic rows, confirmation by client nonce, retry) is shared with the phone
in the orchestrator, and its chrome reads from the --wa-* tokens: a glass header with
a hairline and a subtitle under the name, the cream end-to-end-encryption card at
the head of the thread, 12px balloons with a hairline edge and a tail on the last
message of each same-sender run, the shared day pill, and the same wallpaper the
phone paints. Bubble clocks and day labels print Latin digits in both languages,
as WhatsApp does.
Sides stay logical, so Arabic mirrors the whole thread: outgoing balloons sit on the reader's leading edge, and the tail follows the balloon rather than a physical edge.
The header carries no call buttons, on either width, because there is no telephony backend to answer them.
The type is the phone's own
The messaging route asks the operating system for its interface font instead of the app's Arabic face. On an iPhone or a Mac that resolves to SF Arabic, the font iOS gives WhatsApp, which is why the block reads like the app rather than like the rest of the site. Apple lists SF Arabic beside SF Pro in its Human Interface Guidelines and calls it a contemporary interpretation of the Naskh style.
Nothing is downloaded, and nothing is bundled. Apple's license does not allow the font to be shipped with a website, so the block names the system font and lets the browser pick the right face per character. On Android or Windows the browser supplies that platform's own Arabic interface font, the same way WhatsApp Web does.
Every other page keeps Thmanyah for Arabic.
Unread badge
MessageMailIcon in the platform header fetches /api/messages/unread-count on mount and on tab focus, and increments live via the Socket.IO message:new event.
Its link to /messages prefetches only on hover, touch or focus. The route group has its own dictionary provider, so the automatic viewport prefetch pulled 684 KB on every dashboard open (fixed 2026-09-13).
Notifications
| Trigger | Recipients | Priority |
|---|---|---|
| New message | all participants except sender | normal |
@mention | mentioned users | high |
| Participant added | the added user | normal |
Dispatched via notification-helpers.ts (in-app + email; mentions and participant-added text branch on the school's preferredLanguage). Notifications can also fan out to WhatsApp when the school has it enabled.
Search
fullTextSearchMessages({ conversationId, query, limit, offset }) — PostgreSQL tsvector / tsquery ranked search, backed by a GIN expression index. The last term gets :* prefix matching (search-as-you-type), Arabic text is searchable, and /api/messages/search uses this path (not ILIKE).
globalSearch({ query, conversationTypes, limit }) — across every conversation the user can access.
Server actions and queries
28 server actions in src/components/school-dashboard/messaging/actions.ts:
| Category | Actions |
|---|---|
| Conversations | createConversation, updateConversation, archiveConversation, leaveConversation, pinConversation, muteConversation, unmuteConversation, toggleConversationWhatsApp |
| Messages | sendMessage, sendMessageFromForm, editMessage, deleteMessage, forwardMessage, loadMoreMessages |
| Reactions | addReaction, removeReaction |
| Read receipts | markMessageAsRead, markConversationAsRead |
| Participants | addParticipant, removeParticipant |
| Stars | starMessage, unstarMessage, getStarredMessages |
| Search | searchMessaging, searchConversationMessages |
| Fetch / poll | fetchConversationData, pollNewMessages, pollConversationUpdates |
Modeled but not implemented as actions: message pinning (
PinnedMessagemodel +getPinnedMessagesquery exist, but no write action), drafts (MessageDraftmodel exists, no action), and invites (ConversationInvitemodel exists, no action). These are schema-level only.
Queries (queries.ts) — 28 read functions + 5 builders: getConversationsList (paginated), getConversationsForPoll (count-free, for the 15s fallback), getConversation, getOrCreateDirectConversation, getMessagesList, getMessagesWithCursor (infinite scroll), getUnreadMessageCount (raw SQL), getUnreadCountsPerConversation, fullTextSearchMessages, globalSearch.
sendMessage(input: {
conversationId: string
content: string
contentType?: "text" | "image" | "file"
replyToId?: string
metadata?: Record<string, unknown>
})
createConversation(input: {
type: "direct" | "group" | "class" | "department" | "announcement"
title?: string
participantIds?: string[]
departmentId?: string
})Schema
11 Prisma models in prisma/models/messages.prisma: Conversation, ConversationParticipant, Message, MessageAttachment, MessageReaction, MessageReadReceipt, TypingIndicator, MessageDraft, PinnedMessage, ConversationInvite, StarredMessage. WhatsApp models (WhatsAppSession, WhatsAppMessage, WhatsAppGroup, …) live separately in prisma/models/whatsapp.prisma.
Message carries no lang field — message bodies are user-to-user content, translated on demand at render time (via getText() for contact names), not stored bilingually.
Indexes:
Conversation—[schoolId, type, lastMessageAt desc],[schoolId, departmentId],[directParticipant1Id, directParticipant2Id, type]Message—[conversationId, createdAt desc],[senderId, createdAt],[replyToId],[conversationId, isSystem],[whatsappMessageId],[forwardedFromId]ConversationParticipant—@@unique([conversationId, userId]),[userId, isActive],[conversationId, role]TypingIndicator—[schoolId],[conversationId, expiresAt]
Enums: ConversationType (5), MessageStatus (5), ParticipantRole (5).
Seed
pnpm db:seed:single messages # conversations, messages, reactions, receipts
pnpm db:seed:single messaging-demo # authored inboxes for the student/teacher/parent demo accounts
pnpm db:seed:single messaging-phones # StaffMember + phone numbers for WhatsApp testingmessages pairs teachers and students at random, which leaves the accounts a
demo is driven from nearly empty. messaging-demo fills them: it resolves
counterparts from the real graph (the student's own section teachers, homeroom
teacher and classmates) and writes multi-turn dialogues spread across the past
fortnight, with a few threads left unread and one pinned. It runs as part of
messages, and re-running it changes nothing — existing conversations are never
rewritten.
Mobile tabs
The mobile view has five tabs, all live:
| Tab | What it shows |
|---|---|
| Chats | the conversation list |
| Updates | announcements the reader is an audience for — school-wide, their role, their section or grade |
| Calls | the reader's own live classes, marked attended, missed, upcoming or live |
| Communities | the school as one community, with its class and department rooms |
| Settings | the reader's profile over links to profile, notifications and the dashboard |
Updates and Calls load on first open rather than with the conversation list. Announcement visibility is narrowed per reader: a student never sees a staff-only notice, and ADMIN or DEVELOPER sees every published one.
See also
- Notifications — message dispatch
- Multi-tenancy — RBAC + scoping
On This Page
RoutesConversation typesRBACPlatform role × conversation typeParticipant role within a conversationMessage lifecycleFile attachmentsReal-time deliveryWhatsApp integrationPairing and using itContacts sidebarOn a phoneOne design, both widths — one tree per widthThe type is the phone's ownUnread badgeNotificationsSearchServer actions and queriesSchemaSeedMobile tabsSee also