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

Messages

PreviousNext

In-platform messaging — direct, group, class, department, and announcement conversations with attachments, reactions, search, and WhatsApp dual-delivery.

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/, not messages/. All component code lives in src/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.

RoutePurpose
/messagesSidebar + chat interface
/messages?conversation=XDeep link to a specific conversation

File-system path: src/app/[lang]/s/[subdomain]/(school-messaging)/messages/page.tsx.

Conversation types

TypePurposeAuto-participantsKey fields
direct1-to-1 privateboth usersdirectParticipant1Id, 2Id
groupMulti-user groupcreator as ownertitle, description, avatar
classLabel only (no classId)——
departmentDepartment-wide channeldepartment membersdepartmentId
announcementAdmin / DEVELOPER broadcastall school memberstitle, read-only for non-admins

Creatable today: only direct and group. The create dialog (new-conversation-dialog.tsx) exposes just those two tabs. class, department, and announcement exist in the ConversationType enum 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 direct and group have a creation UI today (see note above), so the class / department / announcement columns describe permission scaffolding, not reachable flows.

Platform role × conversation type

RoleDirectGroupClassDepartmentAnnouncement
DEVELOPERYYYYY
ADMINYYYYY
TEACHERYYY (own)Y—
ACCOUNTANTYY———
STAFFYY———
STUDENTYY (create)———
GUARDIANY————

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

RoleSendEdit ownDelete ownAddRemovePinManage
ownerYYYYYYY
adminYYYYYYY
memberYYYgroups—Y—
read_only———————
guest———————

Message lifecycle

StatusMeaning
sendingClient-side optimistic
sentServer received and persisted
deliveredDelivered to recipient's client
readRecipient has viewed
failedSend failed (validation, network, permissions)
Content typeRendering
textPlain text with link detection
imageInline preview with lightbox
fileFile card with download
systemCentred, 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_SECRET are 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 by cf/entry.cjs, state in the Neon database evolution). 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

ElementLocationPurpose
W toggleChat headerEnable / disable WhatsApp per conversation
W badgeConversation cardMarks WhatsApp-enabled conversations
Status iconMessage bubbleWhatsApp 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 unless WHATSAPP_WEBHOOK_SECRET is set — the per-instance webhook URL carries ?secret= automatically, built from WHATSAPP_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 scalar whatsapp* columns on Message mirror 1:1 state, while groups write one MessageWhatsappDelivery row 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

TriggerRecipientsPriority
New messageall participants except sendernormal
@mentionmentioned usershigh
Participant addedthe added usernormal

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:

CategoryActions
ConversationscreateConversation, updateConversation, archiveConversation, leaveConversation, pinConversation, muteConversation, unmuteConversation, toggleConversationWhatsApp
MessagessendMessage, sendMessageFromForm, editMessage, deleteMessage, forwardMessage, loadMoreMessages
ReactionsaddReaction, removeReaction
Read receiptsmarkMessageAsRead, markConversationAsRead
ParticipantsaddParticipant, removeParticipant
StarsstarMessage, unstarMessage, getStarredMessages
SearchsearchMessaging, searchConversationMessages
Fetch / pollfetchConversationData, pollNewMessages, pollConversationUpdates

Modeled but not implemented as actions: message pinning (PinnedMessage model + getPinnedMessages query exist, but no write action), drafts (MessageDraft model exists, no action), and invites (ConversationInvite model 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 testing

messages 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:

TabWhat it shows
Chatsthe conversation list
Updatesannouncements the reader is an audience for — school-wide, their role, their section or grade
Callsthe reader's own live classes, marked attended, missed, upcoming or live
Communitiesthe school as one community, with its class and department rooms
Settingsthe 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
PermissionsIntegration Flow

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

Built by Databayt ·

Welcome to balqalam.

A great journey is about to begin.