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

Conference

PreviousNext

Video conferencing — one self-contained block at src/components/school-dashboard/live, mirrored 1:1 to the /live route. External pasted-link is live; the LiveKit SFU is four env vars from live (recording is a separate gate); native Meet/Zoom/Teams adapters are wired but dark.

One self-contained block at src/components/school-dashboard/live/, mirrored 1:1 to the /live route. It runs three meeting back-ends behind a single UI: the external pasted-link provider is live everywhere; the LiveKit SFU (in-app rooms + recording) is fully coded and gated only on four LIVEKIT_* env vars — see RUNBOOK.md for the LiveKit Cloud quick path; recording is a separate gate and no longer blocks a room; the native Meet / Zoom / Teams adapters create real meetings via each vendor's API but ship dark until OAuth credentials land.

How it works

One picture of the whole thing: what the school decides once, what the app does on its own every school day, and what a student actually sees.

Three things are worth reading twice:

  • Going online never closes the building. Online is something you add to a class, not something that replaces it. A school can be open and online on the same day.
  • Nobody schedules classes one at a time. The school says how it teaches; the timetable it already keeps becomes the live classes, every morning, on its own.
  • Attendance and recordings look after themselves. Nobody ticks a register, and nobody uploads a video.

Usage guide

If you run the school

  1. Say how the school teaches. School → Configuration → Live classes (the same panel is the Settings tab inside Live classes). Pick In person, Online, or Hybrid. In person means no live rooms at all, whatever any section says — everything below only matters for the other two.
  2. Choose the meeting back-end. In-app room keeps the class inside the app — recording, attendance, whiteboard, the lot. External link uses Google Meet / Zoom / Teams URLs and works with no infrastructure. You can pick the in-app room before it is provisioned; classes quietly run on external links until it lands, then switch over on their own.
  3. If you are on external links, give classes somewhere to go. Set a standing meeting link as the floor — every class without a link of its own lands there — and add a link per class over time. Meeting-link coverage on the same page tells you how far you have got.
  4. Decide what a class is allowed to be. Recording on or off, how long recordings are kept, the notice students see when a class is recorded, and whether recordings are published to the lesson automatically. Then: may guardians watch, do students enter muted, and which room tools are on — chat, raising hands, polls, the whiteboard, letting students share a screen.
  5. Set the timing. How many minutes before a class the reminder goes out, and — if you want attendance marked automatically — when a student counts as late, as present, and as having left early.
  6. Tune the exceptions. In hybrid, by grade and by section override the school default in both directions. "School default" means inherit.
  7. When something closes the school, use Go online temporarily with a start date and, if you know it, an end date. Today's classes are created while you are still on the page. Clearing the start date ends it — there is no separate cancel button to forget.

If you teach

  1. You do not create your classes. They appear for every slot on your timetable, each morning.
  2. Start from where you already are — the Join button on your timetable's Today card, or the Live classes dashboard. Starting the class is what opens the room for everyone else.
  3. The class opens on its own card — the subject's artwork, the grade, who is teaching, when it runs, and one Join button. Nothing is started and nobody is called into the room until you press it, so you can open a class to look at it without starting it. Over the artwork sit Back, ADD — which saves the class to your own calendar — and share, which hands the class's link to whoever you pick.
  4. The page carries on under the card. The card stops short of the bottom of the screen on purpose — four fifths of it, on a phone as on a laptop. Below it are four rows: the rest of the section's day, each class opening in its room when it is running and offering its recording when there is one; the exams, assignments and links attached to this class; the subject's own self-study lessons; and the teacher with the students the class is taught to.
  5. In the room you have your microphone and camera, screen share, a whiteboard, any PDF attached to the lesson as slides with the page synced to everyone, polls, a questions list you can mark answered, the raised-hands queue, and the ability to remove someone.
  6. You can change one class without changing the school: the class form has its own Students enter setting — school default, muted, or mic and camera on.
  7. End the class when you are done. Attendance is written from the time each student spent in the room, across drop-outs and reconnects. If the class was recorded, the session page shows the recording being prepared, then ready.

If you are a student

  1. Join appears where your day already is — the Today rows of your timetable, the Current/Next card, the Live classes page, and the reminder notification. It shows up when the class is running, or about to.
  2. You enter muted with your camera off unless the teacher opened the class differently. Turn them on when you are asked to.
  3. Raise your hand, ask a question, answer a poll, or use the chat — whichever of those your school has switched on.
  4. A weak connection is handled for you. The picture steps down before it breaks, and finally to audio with the slides. If you drop out, you are offered Rejoin rather than being thrown out.
  5. If you missed it and the class was recorded, it shows up as the lesson's video. It plays in the app, it cannot be downloaded, and every frame carries an invisible mark tied to your account.

If you are a parent

Open the Parent portal → your child → Timetable. Today's classes carry the same Join, and you enter as an observer — you can watch and listen, but not speak, use the camera, or ask questions. Your school can switch this off entirely.

Structure

src/components/school-dashboard/live/— one block, mirrored 1:1 to /live
content.tsx— server list (fetch + batched translate → table)
queries.ts— read-only Prisma builders
actions.ts— server-action barrel
list-actions.ts— list-layer CRUD (create/update/delete/list)
online-policy.ts— is this online, over which back-end, delivered how (one resolver)
day-window.ts— school-calendar day math (pure, DST-safe)
school-calendar.ts— holiday gate for the sweep (write side only)
actions/— rich sessions-layer server actions
helpers.ts— requireContext · canAccessSession
slot-session.ts— day-qualified slot lookup + cron writer
materialize-day.ts— online policy → one school day of sessions
open-room.ts— loose mode — one standing room per section per day
sessions.ts— lifecycle state machine
join-core.ts— shared eligibility + token mint (action + route)
tokens.ts— join → 5-min JWT
recordings.ts— list / signed-URL / delete
notifications.ts— 5 events → notification hub (in-app + email)
attendance-sync.ts— presence → attendance (opt-in, LiveKit-only)
settings.ts— capacity · recording · attendance sync · online window + mode + standing link
moderation.ts— kickParticipant
recurring.ts— link carry-forward across terms
authorization.ts— PERMISSION_MATRIX (strict RBAC)
list-permissions.ts— UI gating + CRUD guards (list layer)
validation.ts— Zod (sessions)
list-validation.ts— Zod (table)
list-params.ts— nuqs URL cache
nav.tsx— heading + tab strip for the (app) surfaces
landing/— the /live landing page
content.tsx— composes the sections; gates on role + online state
status-hero.tsx— the green banner: the headline beside a line mark
now-strip.tsx— live / coming-up, with catalog subject art
session-row.tsx— the shared article row, drawn by both blocks
past-shelf.tsx— classes already taught, beside their subject tiles
readiness-band.tsx— can the school teach online (admins only)
role-guide.tsx— what THIS role can do here
get-started-band.tsx— pitch + 3 setup steps, only while not yet online
reveal.tsx— the one motion wrapper (get-started band only)
viewer.ts— role → what the page offers (single source)
types.ts— section props · LandingSession · policy · readiness
table.tsx— DataTable + grid
columns.tsx— column defs + action menu
form.tsx— create/edit 5-step wizard (modal)
form-steps.tsx— wizard steps: basics · schedule · meeting · references · access
schedule-form.tsx— standalone scheduler
settings-form.tsx— admin school-policy form
section-recording-policy.tsx— per-section recording opt-out
section-online-policy.tsx— per-section online override (inherit/on/off)
detail.tsx— single-session detail
room.tsx— pre-join title card, then the in-app LiveKit room
participants-panel.tsx— HOST/CO_HOST in-room kick UI
recordings.tsx— recordings list
recording-player.tsx— lazy signed-URL player
network-test.tsx— LiveKit RTT/ICE diagnostic
loading-skeleton.tsx— loading skeleton
types.ts— domain types + RoomJoinTicket
error-map.ts— error-code → string
network-protocol.ts— pure ICE-path classifier
livekit/— SFU server SDK wrappers
client.ts— singletons + readiness
token.ts— role → VideoGrant
rooms.ts— room lifecycle
egress.ts— recording egress
recording-urls.ts— signed S3 URLs
room-naming.ts— sch-{schoolId}-lc-{id}
webhook.ts— HMAC verify + idempotent router
providers/— link-provider adapters
types.ts— adapter contract
external.ts— pasted link (live)
google-meet.ts— wired, dark
zoom.ts— wired, dark
teams.ts— wired, dark
token-cache.ts— OAuth token cache
index.ts— registry
README.md— provisioning reference
RUNBOOK.md— LiveKit 6-gate provisioning
README.md— feature purpose, routes, status
ISSUE.md— open backlog and follow-ups
CLAUDE.md— block-level context

Routes mirror this block (see Routes). The 6 Prisma models live in prisma/models/conference.prisma. Tests live under src/tests/ (URL-mirror reorg), not in a __tests__/ folder inside the block.

Components

Two permission/validation layers coexist on purpose: the rich sessions layer (authorization.ts · validation.ts) is the strict runtime gate for the LiveKit room flow (join / token / start / end), while the list layer (list-*.ts) is the CRUD gate for the dashboard table and external-link sessions.

FilePurpose
content.tsxServer — fetches list + form options in one parallel pass, batched translation, renders the table.
actions.ts + actions/Server actions: sessions (lifecycle), tokens (join), recordings, notifications, settings, moderation (kick), recurring (link carry-forward), helpers.
list-actions.ts · queries.tsList-layer CRUD (create/update/delete/list) + read-only query builders.
authorization.tsPERMISSION_MATRIX — 10 action verbs → allowed UserRole[]; assert/check guards.
list-permissions.tsUI gating (tabs + show/hide flags per role) for the rich + list layers.
validation.ts · list-validation.tsZod schemas + inferred types (rich sessions / table forms).
columns.tsx · table.tsx · form.tsx · schedule-form.tsx · list-params.tsDataTable + grid client surface and the two create forms (modal + standalone page).
landing/ · nav.tsxThe /live landing page (green hero banner · live/coming-up strip · catch-up shelf · two ranked recordings · readiness band · role guide · setup band) and the heading + tab strip over the (app) surfaces.
detail.tsx · room.tsx · participants-panel.tsxSession detail · in-app LiveKit room · HOST/CO_HOST moderation overlay (kick).
recordings.tsx · recording-player.tsx · section-recording-policy.tsxRecordings list · lazy signed-URL player · per-section recording opt-out.
settings-form.tsxAdmin school-policy form (retention / max-concurrent / max-duration / default recording) + term carry-forward.
network-test.tsx · network-protocol.tsLiveKit connectivity diagnostic + pure ICE-path classifier (getLiveKitReadiness() / classifyFromStats).
types.ts · error-map.tsDomain types + RoomJoinTicket · error-code → translated string.
livekit/SFU SDK wrappers: client (config + readiness), token, rooms, egress, recording-urls, room-naming, webhook.
providers/Link-provider adapters: types, external (live), google-meet/zoom/teams (dark), token-cache, index registry.

Data flow

landing (/live)          landing/content.tsx (server) → hero banner · live strip · catch-up shelf · recordings pair · readiness · role guide
   └─ View sessions
index (/live/dashboard)  content.tsx (server) → table.tsx (client) → form.tsx (modal create)
   └─ row Join / detail
detail (/live/[id])      detail.tsx → Join (live/scheduled) | Recordings (ended)
   └─ Join
room  (/live/[id]/room)  joinLiveClass → eligibility → 5-min JWT → room.tsx (LiveKit prebuilt)
                                              ↑ refreshLiveClassToken every ~60s re-runs eligibility
recordings (/[id]/recordings)  recordings.tsx → recording-player.tsx (signed S3 URL, 4h; re-mints on expiry via onError)
  • Session state machine (actions/sessions.ts): scheduled → live → ended, plus cancelled and failed. startLiveClass provisions the SFU room (checks conferenceMaxConcurrent); endLiveClass is idempotent. The webhook is the authoritative writer for live (room_started) and ended (room_finished).
  • Eligibility is resolved lazily per join (actions/tokens.ts): TEACHER→HOST (own session) / CO_HOST, STUDENT→PARTICIPANT (section match), GUARDIAN→OBSERVER (child in section), ADMIN→CO_HOST, DEVELOPER→HOST. Kicked users (status="removed") are blocked at the next token refresh.
  • Recordings (pending → processing → ready → expired): LiveKit Egress → S3 me-central-1; per-school retention enforced by the daily cron.
  • Notifications (actions/notifications.ts): all 5 live_class_* events resolve their audience (teacher + section roster + guardians) and dispatch through the shared hub (dispatchNotificationsToAudience), so they reach the in-app bell and email, honour per-user channel preferences, and carry an absolute action URL. Both the in-app room path (sessions.ts) and the external pasted-link path (list-actions.ts create / reschedule / cancel) fan out.
  • Attendance (actions/attendance-sync.ts, opt-in via School.conferenceAttendanceSync): when a session ends (webhook room_finished or the stale-session cron), syncConferenceAttendance marks each section student PRESENT/LATE from their in-room presence and ABSENT for roster non-joiners, writing Attendance rows with method VIRTUAL. Requires a sectionId + timetableId; LiveKit only — external links carry no presence data. Idempotent (section unique key, revive-on-update).

Routes

RouteLayoutRendersRoles
/liveschool-dashboardlanding/content.tsx — the landing pageall 7 school roles
/live/dashboardschool-dashboard (app)DataTable indexall 7 school roles
/live/scheduleschool-dashboard (app)schedule-form.tsxDEVELOPER · ADMIN · TEACHER
/live/settingsschool-dashboard (app)settings-form.tsx + section-recording-policy.tsxDEVELOPER · ADMIN
/live/network-testschool-dashboard (app)network-test.tsxDEVELOPER · ADMIN (env-gated)
/live/[id]school-dashboarddetail.tsxall 7 school roles
/live/[id]/recordingsschool-dashboardrecordings.tsxall except ACCOUNTANT (academic, not finance)
/live/[id]/room(live-room)room.tsxany authenticated session participant
/live-classes/*—redirect → /livelegacy compatibility shim (pre-rename links)

(app) is a route group: it adds no URL segment, only the shared heading and tab strip (Sessions · Schedule · Settings · Network test, narrowed by role). /live and /live/[id] sit outside it — the landing page owns its own hero, and a session detail is a leaf reached from a row rather than a tab.

The room route lives under a separate (live-room) group with a bare full-viewport layout (no sidebar / dashboard chrome) so the SFU stream owns the screen; it is force-dynamic for OOM-safe builds. API (external / scheduler-invoked / polled → stay routes, not server actions): api/webhooks/livekit (HMAC), api/live/token (the in-room ~4-min token refresh — a GET route because a server-action poll would ship a full RSC re-render per response), api/cron/live-class-reminders (every 15 min, 5–20-min idempotent window), api/cron/end-stale-live-classes (every 30 min — closes sessions stuck live past their end + runs the attendance sync), api/cron/expire-live-recordings (daily).

Schema — prisma/models/conference.prisma

LiveClass* → Conference*; every DB table / column / enum-type name preserved via @@map/@map → zero-change migration (byte-identical migrate diff vs baseline).

ModelPurpose
ConferenceA session. provider (livekit|external), meetingUrl?, visibility (section|school), catalogLessonId?.
ConferenceParticipantOne row per invited user + connection telemetry (RTT, TCP-fallback flag).
ConferenceRecordingEgress metadata + S3 location + retention expiresAt.
ConferenceEventWebhook audit log + eventId @unique idempotency.
ConferenceLinkSet-once recurring link [schoolId, subjectId, sectionId, termId].
ConferenceResourceAttached reference — exactly one of schoolExamId / schoolAssignmentId / http(s) url per row.

Enums ConferenceProvider · ConferenceStatus · ConferenceRecordingStatus · ConferenceParticipantRole · ConferenceParticipantStatus · ConferenceVisibility; 5 School.conference* config columns (conferenceRetentionDays 90 · conferenceMaxConcurrent 50 · conferenceMaxDuration 120 · conferenceRecordingDefault true · conferenceAttendanceSync false).

Access control — private vs school-wide

visibility decides who may see and join a session, on top of the absolute tenant boundary:

  • section (default) — the session's section roster joins as PARTICIPANT, their guardians as OBSERVER; a session with no section is host-only (a private meeting).
  • school — every member of the school: students PARTICIPANT, guardians OBSERVER, staff/accountant PARTICIPANT. For assemblies, PD webinars, parent town halls. Scheduling one notifies the whole school through the notification hub.

There is deliberately no cross-school or anonymous tier — eligibility, list scoping, detail reads, and recordings access all enforce the same rule (resolveParticipantRole, canAccessSession, buildLiveClassWhere). Meeting and reference URLs are scheme-locked to http(s) — javascript:/data: URIs are rejected at validation.

References — lesson, quiz, assignment, links

One catalogLessonId FK links a session to a catalog lesson; the session page then surfaces the lesson's videos, attachments, materials, and practice-question count automatically. Quizzes/exams (SchoolExam — a quiz is examType: QUIZ, not a separate model), assignments, and ad-hoc links attach as ConferenceResource rows — tenant-verified before write, replaced as a set on update. The wizard's References step loads its pickers per chosen subject (getLiveClassReferenceOptions, staff-gated).

The create wizard

The dashboard form is a 5-step compact wizard (form.tsx + form-steps.tsx) on the house stepped-modal pattern — Basics → Schedule → Meeting → References → Access, a handful of fields per step.

Basics opens with the Class picker: the school's real timetable slots for the active term ("Mathematics · Grade 1-A · Sunday 08:00"). This is what makes a session the online delivery of a physical class rather than a standalone meeting — picking a slot fills in the teacher, subject, section, the period's time window and the next date that weekday falls on, and stores the timetableId. That anchor is load-bearing: it is what lets the session mark attendance from who actually joins (the sync needs a section roster and a period) and what makes the class show up as a live/scheduled dot on the weekly timetable grid. The server re-derives teacher/subject/section from the slot row, so the schedule — not the form — is the source of truth. Leave the picker on "Not tied to a class" for assemblies, parent town halls and one-off tutorials. The anchor cannot be changed later (re-anchoring would re-key attendance that has already been written).

The subject and lesson pickers follow the catalog, scoped to the section's grade: subjects are the ones this school adopted for that grade (shown under the school's own name for them), and catalog lessons come from that grade's chapters. Every session is created scheduled; going live and ending are separate, guarded steps.

The Meeting step picks the back-end: In-app room (LiveKit — default whenever isLiveKitConfigured(), with recording + max-participants knobs) or External link (pasted Meet/Zoom/Teams URL + optional weekly-recurring default). Provider is immutable after creation; Join is provider-aware everywhere (table menu, detail page, and the room route hands external sessions to their vendor URL).

Teaching online

The first setting is how the school teaches — in person, online, or hybrid — under School → Configuration → Live classes (the same panel is the Settings tab of Live classes). In person means no live rooms and no join buttons, whatever any section says. Online means every timetable slot is a live class. Hybrid is where the choices below live: a default for sections, per-section overrides, and a temporary go-online window. Changing to in person applies to new sessions; the day's already-scheduled classes stay and close on their own.

Beyond the mode, the same panel holds: the meeting back-end and standing link; how online classes run (timetable, open room, both); recording on/off, retention, the notice students see on joining a recorded class, and whether recordings are published to the lesson automatically; whether guardians may watch; whether students enter muted (a class can override it); which room tools are on — chat, raising hands, polls, the whiteboard, student screen share; how many minutes before a class the reminder goes out; attendance thresholds; per-grade and per-section online overrides; and a read-only statement of the content-protection policy with recording storage used.

A school that delivers its classes online does not schedule them one at a time. It declares the intent once, and every timetable slot becomes a live class.

Going online never closes the building. Online delivery is additive — a live channel opened alongside the physical class — so a school can be open and online on the same day, online on Sunday and in person on Monday, or online for one section and not another. Nothing here cancels a lesson.

Three independent switches can put a class online, and they combine rather than compete:

SwitchWhereMeans
Teach online/live/settingsThe standing "we are an online school" setting.
Online teaching by sectionsame page, per sectionOverrides the school default in both directions. "School default" inherits.
Go online temporarilysame page, a date rangeThe emergency switch — see below. Lifts the school default for those days.

The per-section decision always wins over the two school-wide ones, including during an emergency: the window raises the school's default, it does not overrule a choice someone made about a section. A section held back that you now want online is one click — set it back to "School default".

Going online at short notice

Weather, a closed road, an outbreak, a war. /live/settings → Go online temporarily:

  • From — the first day. Required.
  • Until — the last day, inclusive. Leave it empty to stay online until further notice, which is usually what you know on day one.
  • Reason — free text, shown to staff on this page.

Both dates are read in the school's own timezone and cover whole school days. Clearing From ends the period — there is no separate "cancel" button to forget. When the end date passes, the school simply goes back to whatever it was doing, with no manual step.

Saving takes effect immediately: today's classes are created while you are still on the page, rather than at the next quarter-hour.

Timetable-bound or free timing

How online classes run is a separate choice from whether they run:

  • Follow the timetable — one live class per timetable slot, starting and ending with the period. The strict version, and the one that feeds period attendance.
  • Open room, any time — one standing room per section for the whole teaching day, with no period boundaries. A school that went online because of a storm rarely reproduces its bell schedule on day one, and a school that is online by design may not want one at all. Hosted by the section's homeroom teacher. Because it is not tied to a period, it does not write period attendance, and it does not appear on the timetable cards either — open rooms are reached from Conference.
  • Timetable + open room — both, so a section can follow its schedule and still have somewhere to meet in the gaps.

Set it up

  1. Choose the back-end. /live/settings (ADMIN / DEVELOPER) → Teach online, then Meeting back-end:

    • External link — Google Meet / Zoom / Teams URLs. Works today, no infra.
    • In-app room — LiveKit. Selectable immediately, but it only takes effect once the SFU is provisioned (the six gates in conference/RUNBOOK.md). Until then classes quietly run on external links and switch over on their own the day the infra lands — no migration, no re-setup.
  2. For external links, set one link per class. Create a session from the wizard with Save as default, which stores a recurring ConferenceLink for that (subject, section, term). This is where a materialized class gets its meeting URL — a slot with no link is skipped rather than published with a dead Join button. At term rollover, Carry forward recurring links on the same settings page clones them into the next term.

    Meeting-link coverage on the same page tells you how far you have got: how many (section, subject) pairs in the active term have their own link, and which do not. Check it before you need it.

  3. Set a standing meeting link as the floor. A school going online at short notice will not have a link per class ready. Standing meeting link is used for any class that has none of its own, so the flip produces joinable classes instead of nothing at all. It is one shared room — every class falling back to it lands in the same meeting — so treat it as the emergency floor and add per-class links as you go. An open room has no other source of a URL, since it belongs to no subject.

  4. Tune per section if the school is hybrid. The Online teaching by section list overrides the school-wide switch in both directions: hold one section back from a school that went online, or put one section online in a school that has not. "School default" means inherit.

What then happens

Every 15 minutes the reminders cron materializes the current school day's online slots — one Conference row per slot, per day, in the school's own timezone. It mirrors the timetable's own filter (active term, that weekday, weekOffset: 0, no break periods, slots that have both a section and a teacher), so it can never invent a class the schedule doesn't show. Re-runs are no-ops, and a class an admin cancelled is not resurrected.

Nothing is created on a day the school has declared closed — a HOLIDAY or CANCELLED entry in the schedule exceptions stops the sweep for that day, so nobody is reminded about a class on Eid. The timetable itself still shows the day, with a "school is closed today" notice over it: holiday entries are typed by hand, and a wrong one should not make a whole timetable disappear with no explanation. Periods that have already finished are skipped too, so flipping the switch at lunchtime does not publish Join buttons for the morning.

Because a real row exists before anyone clicks, the rest of the product wakes up: students get the start-reminder notification, the weekly grid shows the scheduled/live dot on the slot, every timetable card marks the class Online — beside the room, not instead of it, because the room is still where the class meets for anyone who can get there — the teacher's Current card offers Start, and — on the LiveKit path with attendance sync enabled — presence is written back as VIRTUAL attendance when the class ends. Sessions nobody ever starts are closed as cancelled by the end-stale cron rather than lingering.

Each session's own page says how attendance is being handled for it — marked automatically when it ends, or to be marked as usual, with a link to the register. External meetings carry no attendance data at all, which is the common case for a school that has just gone online, so it is stated rather than left to be discovered from an empty register.

What each back-end gives you

External linkIn-app room (LiveKit)
Works today✅⛔ six infra gates
Join from the timetable✅✅
Reminders + notifications✅✅
Live dot on the weekly grid✅✅
Recording⛔ vendor-side only✅ Egress → S3, per-school retention
Attendance from presence⛔ no presence data✅ opt-in
In-room moderation (kick)⛔✅

How a student joins

Five surfaces reach a live class, and they differ in how tightly they gate on time:

SurfaceGate
Timetable Today rows (student · teacher · guardian)in progress, or starting within 10 min (isRowLiveJoinable)
Timetable Current/Next cardthe current class, or the next within 10 min (isLiveJoinable)
/live landing "now" striplive now + upcoming, section-scoped
/live/dashboard tableany live or scheduled row the viewer can see
Parent portal → child → Timetablea "Today" strip above the weekly table
Notificationstarted links straight to the room; the rest to the session detail
Mobilelive_class on /api/mobile/timetable/:userId, joined via GET /api/mobile/live/:id/join

The timetable weekly grid deliberately shows only a live dot — awareness, not a Join affordance.

A LiveKit session opens the in-app room at /live/[id]/room; an external session opens its vendor URL directly. The room route redirects external sessions after the enrollment-gated read, so either provider works from every surface above.

Only the started notification deep-links to the room. startingSoon fires 5–20 minutes before the class, and a non-HOST joining a still-scheduled session is refused — an early room link would land a student on an error rather than a waiting room.

In the room

The room is the app's own — every control is labelled from the dictionary and the layout follows the page direction. Its chrome is the lesson player's phone layout, the way Apple's video player lays it out, with the class's own controls in the player's slots:

  • Top row: leave (✕) on its own; a glass pill with the people list, fill / fit, and share the class; and at the far end the speaker, which silences the class on this device only.
  • The centre: three discs where a film's player keeps rewind · play · forward — camera, the microphone (the large one, red while muted), and raise hand (teacher: share screen).
  • The bottom: the subject with ⋯ beside it — connection quality, microphone and camera choice, and for the teacher the whiteboard and slides — then the class's clock (time elapsed, a read-only track, time left), then the Discussion capsule (chat, questions, poll, with a count) and, for the teacher, Raised hands while any are up.
  • The chrome fades three seconds after the last touch and comes back on a tap of the picture; an open menu or panel keeps it up.

What a class can do in it:

  • Teacher: microphone, camera, screen share, whiteboard, slides (any PDF attached to the lesson, page-synced to everyone), polls, a questions list with "mark answered", the raised-hands queue, and moderation (remove).
  • Student: microphone and camera (both off on entry), raise hand, ask a question, vote, chat. Guardians observe only.
  • Delivery quality adapts to the student's connection: a sustained poor patch steps the teacher's video down (720 → 360 → 180) and finally into audio + slides — the banner says so — and a sustained good stretch steps it back up. A manual choice in the Quality menu overrides the ladder.
  • Reconnecting shows an overlay; a lost connection offers Rejoin instead of ejecting. Being removed by the teacher, the class ending, or the room being opened on another device each say exactly that.
  • Attendance is recorded from the time spent in the room, across reconnects — nothing to tap.
  • Closed polls and every question are kept on the session as events.

After the class

When recording is on, the finished recording is published as the lesson's video in Lumos (visible to the school, downloadable), and the session page moves through recording is being prepared → recording available — or says plainly that the recording failed.

Protecting videos and materials

Recordings, lesson videos and materials are watched inside the app and never downloaded: there is no download control, no picture-in-picture or casting, and materials open in an in-app viewer rather than as files. Every one of those surfaces — the class room included — carries a watermark in two layers: the viewer's account code at 1% opacity, always on and unnoticeable, which a screenshot or screen recording keeps; and the viewer's masked email, which appears only when a capture may be starting (the screenshot keys, PrintScreen, switching away from the window). A browser can neither block nor detect a capture, so this makes a leak traceable rather than impossible; a camera pointed at the screen is not covered. A student's own work — playback position, quiz answers, an assignment hand-in — still queues offline and syncs when the connection returns; /offline shows what is waiting.

Operating it

  • The SFU: four env vars (LIVEKIT_HOST, LIVEKIT_WS_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET). Recording is a separate gate (LIVEKIT_RECORDING_BUCKET plus S3 credentials) and no longer blocks holding a call. /live/network-test reports both.

  • A configured SFU does nothing on its own — School.conferenceProviderDefault is external until a school opts in on /live/settings.

  • The materialization sweep runs on GitHub Actions, not Vercel Cron, while the project is on the free plan (.github/workflows/conference-crons.yml). Without it an online school materializes no sessions after the day it saved its settings — nothing to join, and no reminders.

  • Going live has one writer. Three things can flip a class from scheduled to live — the teacher's Start, a host pressing Join on the title card, and the SFU's own room_started webhook — and whichever lands first now performs the side effects: the "class is live" notification to the roster and the auto-start of the recording. Until 2026-09-04 only the webhook carried them, behind a guard the app's own write had usually already beaten, so on the real join path no class ever notified anyone or recorded. actions/went-live.ts is that writer.

  • An external session cannot be started as a room. It is its meeting link; Start and Join refuse it instead of opening a LiveKit room under a name no webhook can map back.

  • Every online source is swept. The cron's candidate filter names the school default, the per-section override, the per-grade override and the go-online window. Flipping a section or a grade online also materializes today at once, the way the school-wide save does.

  • Check the cron bridge, it does not check itself. From 2026-08-31 to 09-03 every scheduled run failed — the prebuilt deploy lacked the linux-arm64 Prisma engine, so the three live crons and fee-due returned 500 while every page stayed green. binaryTargets now carries both Linux engines and deploy-hobby.sh refuses a bundle without the engine its functions need. One line tells you the bridge's health:

    gh run list --repo databayt/hogwarts --workflow=conference-crons.yml --limit 20

Demo and the hybrid school

pnpm db:seed:single conference turns the demo into a working hybrid school: online by default over LiveKit, two sections in person, five days of history with real presence and VIRTUAL attendance, next-day sessions the cron will find as exists, a substitute hosting one of them, a lesson session carrying an exam, an assignment and a link, three recurring external links, and a declared holiday. It repairs first — break periods, teacher expertise, and a teacher on every teacherless slot — because a slot with no teacher has no host and never goes online.

Every session it writes is dated relative to the day it runs, so the seed rebuilds itself once its newest session has fallen behind today — in the school's own timezone. Re-running it keeps the demo current instead of leaving a student a page with nothing live, nothing upcoming and nothing recent.

Three of those sessions straddle the clock — one mid-lesson, one nearly over, one about to begin — so the landing strip's lead and its two-up all have something to show. All three sit in the demo student's own section, of three different subjects, because their page is scoped to that section and rows outside it are filtered away before they reach the strip.

Rows dated "now" go stale within the hour, and the seed runs at most once a deploy, so the demo repairs its own clock at read time: on a demo tenant the landing checks those three rows before it reads and rewrites them only when they no longer say what they were written to say. That is why /live is never empty there, however long it has been since the last seed.

What "hybrid" means in the product: online is additive. The session detail shows the physical room the class still meets in; the timetable card shows the room with an Online marker beside it; a confirmed substitute hosts the online arm of the class they cover; a lumos lesson shows its live session; and the role home pages carry Join. Recording stays honest — every switch says so when there is no bucket.

Providers

ProviderStatusMissing
External link✅ Live— (universal zero-infra default)
LiveKit SFU🟡 Coded, dormant6 infra gates → conference/RUNBOOK.md
Google Meet🟡 createMeeting wired, darkGoogle Cloud OAuth creds (GOOGLE_MEET_*)
Zoom🟡 createMeeting wired, darkServer-to-Server OAuth creds (ZOOM_*)
Microsoft Teams🟡 createMeeting wired, darkAzure AD creds (AZURE_*, Graph OnlineMeetings)

Each native adapter creates a real meeting through the vendor API (createMeeting), gated by isConfigured(); getRecording/getAttendance still throw ProviderNotImplementedError. Natives persist as provider="external" + meetingProvider="<id>" → no enum migration to ship.

Decisions

  • One block, one provider abstraction. External is the floor every school gets; LiveKit is the opt-in premium ceiling, gated by isLiveKitConfigured(). LiveKit's SFU lifecycle stays out of the providers/ adapter layer (it's room-based, not link-based).
  • The now strip is one lead card and two brief ones. The first card is whatever is running, or the next class to start, and it carries everything: subject, chapter, lesson, the teacher with their portrait, and where the class is in its own clock. The two under it are two text rows — the subject with its grade badge, then the teacher and the time on one line. The hierarchy is bought by simplifying the neighbours, not by decorating the lead: a strip of three equal cards made the live class as easy to miss as the one starting in two hours.
  • A card says what the role does not already know. The page narrows a teacher's strip to the classes they teach, so their cards name the SECTION and drop the teacher's name; a student's rows are all their own section, so theirs name the teacher and badge the grade. Admins, staff, accountants and guardians read across sections and get both. The teacher rule keys on the narrowing having actually applied — a TEACHER account with no Teacher row sees the whole school, and there the name stays.
  • The second shelf is CATCH UP — the classes you missed. It reads presence, not attendance: a ConferenceParticipant row carries joinedAt only once someone actually reached the room, and rows are created lazily at join time, so "no row with a joinedAt" is the signal and it needs no per-school opt-in. Whose presence counts is not always the reader's — a guardian catches up on what their CHILDREN missed. A reader who joins nothing, which is every administrator, sees the shelf degrade to "recently taught". The rows are one horizontally scrolling row of cards, up to twelve, and a card offers the recording when there is a ready one and the reader may watch it.
  • Under the shelf, two recordings ranked for the reader. A recording of a class this reader MISSED outranks one of a class they sat through, and recency only breaks the tie — so two students in the same section are shown different pairs. Only a ready recording qualifies, and only a reader with view_recordings is offered the link.
  • Rename is DB-transparent. @@map/@map keep every table / column / enum-type name.
  • Webhook + crons stay API routes — external / scheduler-invoked; a server action can't receive them.
  • Token TTL = 5 min, client-refreshed. LiveKit JWTs are stateless, so access revocation takes effect at the next refresh boundary (≤5 min), not instantly; kicks are enforced via the status="removed" gate at join.
  • Pending (non-code): LiveKit infra provisioning (runbook ready), native OAuth credentials.

Related

  • Architecture · Pattern · Timetable — Conference.timetableId anchors the teacher/student/guardian Start / Join buttons · Notifications — 5 live_class_* types via the shared hub (in-app + email) · Attendance — opt-in VIRTUAL marks from in-room presence
NotificationsLMS (Lumos)

On This Page

How it worksUsage guideIf you run the schoolIf you teachIf you are a studentIf you are a parentStructureComponentsData flowRoutesSchema — prisma/models/conference.prismaAccess control — private vs school-wideReferences — lesson, quiz, assignment, linksThe create wizardTeaching onlineGoing online at short noticeTimetable-bound or free timingSet it upWhat then happensWhat each back-end gives youHow a student joinsIn the roomAfter the classProtecting videos and materialsOperating itDemo and the hybrid schoolProvidersDecisionsRelated

Built by Databayt ·

Welcome to balqalam.

A great journey is about to begin.