- 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
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
- 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.
- 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.
- 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.
- 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.
- 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.
- Tune the exceptions. In hybrid, by grade and by section override the school default in both directions. "School default" means inherit.
- 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
- You do not create your classes. They appear for every slot on your timetable, each morning.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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.
- You enter muted with your camera off unless the teacher opened the class differently. Turn them on when you are asked to.
- Raise your hand, ask a question, answer a poll, or use the chat — whichever of those your school has switched on.
- 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.
- 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 /livecontent.tsx— server list (fetch + batched translate → table)queries.ts— read-only Prisma buildersactions.ts— server-action barrellist-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 actionshelpers.ts— requireContext · canAccessSessionslot-session.ts— day-qualified slot lookup + cron writermaterialize-day.ts— online policy → one school day of sessionsopen-room.ts— loose mode — one standing room per section per daysessions.ts— lifecycle state machinejoin-core.ts— shared eligibility + token mint (action + route)tokens.ts— join → 5-min JWTrecordings.ts— list / signed-URL / deletenotifications.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 linkmoderation.ts— kickParticipantrecurring.ts— link carry-forward across termsauthorization.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 cachenav.tsx— heading + tab strip for the (app) surfaceslanding/— the /live landing pagecontent.tsx— composes the sections; gates on role + online statestatus-hero.tsx— the green banner: the headline beside a line marknow-strip.tsx— live / coming-up, with catalog subject artsession-row.tsx— the shared article row, drawn by both blockspast-shelf.tsx— classes already taught, beside their subject tilesreadiness-band.tsx— can the school teach online (admins only)role-guide.tsx— what THIS role can do hereget-started-band.tsx— pitch + 3 setup steps, only while not yet onlinereveal.tsx— the one motion wrapper (get-started band only)viewer.ts— role → what the page offers (single source)types.ts— section props · LandingSession · policy · readinesstable.tsx— DataTable + gridcolumns.tsx— column defs + action menuform.tsx— create/edit 5-step wizard (modal)form-steps.tsx— wizard steps: basics · schedule · meeting · references · accessschedule-form.tsx— standalone schedulersettings-form.tsx— admin school-policy formsection-recording-policy.tsx— per-section recording opt-outsection-online-policy.tsx— per-section online override (inherit/on/off)detail.tsx— single-session detailroom.tsx— pre-join title card, then the in-app LiveKit roomparticipants-panel.tsx— HOST/CO_HOST in-room kick UIrecordings.tsx— recordings listrecording-player.tsx— lazy signed-URL playernetwork-test.tsx— LiveKit RTT/ICE diagnosticloading-skeleton.tsx— loading skeletontypes.ts— domain types + RoomJoinTicketerror-map.ts— error-code → stringnetwork-protocol.ts— pure ICE-path classifierlivekit/— SFU server SDK wrappersclient.ts— singletons + readinesstoken.ts— role → VideoGrantrooms.ts— room lifecycleegress.ts— recording egressrecording-urls.ts— signed S3 URLsroom-naming.ts— sch-{schoolId}-lc-{id}webhook.ts— HMAC verify + idempotent routerproviders/— link-provider adapterstypes.ts— adapter contractexternal.ts— pasted link (live)google-meet.ts— wired, darkzoom.ts— wired, darkteams.ts— wired, darktoken-cache.ts— OAuth token cacheindex.ts— registryREADME.md— provisioning referenceRUNBOOK.md— LiveKit 6-gate provisioningREADME.md— feature purpose, routes, statusISSUE.md— open backlog and follow-upsCLAUDE.md— block-level contextRoutes 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.
| File | Purpose |
|---|---|
content.tsx | Server — 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.ts | List-layer CRUD (create/update/delete/list) + read-only query builders. |
authorization.ts | PERMISSION_MATRIX — 10 action verbs → allowed UserRole[]; assert/check guards. |
list-permissions.ts | UI gating (tabs + show/hide flags per role) for the rich + list layers. |
validation.ts · list-validation.ts | Zod schemas + inferred types (rich sessions / table forms). |
columns.tsx · table.tsx · form.tsx · schedule-form.tsx · list-params.ts | DataTable + grid client surface and the two create forms (modal + standalone page). |
landing/ · nav.tsx | The /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.tsx | Session detail · in-app LiveKit room · HOST/CO_HOST moderation overlay (kick). |
recordings.tsx · recording-player.tsx · section-recording-policy.tsx | Recordings list · lazy signed-URL player · per-section recording opt-out. |
settings-form.tsx | Admin school-policy form (retention / max-concurrent / max-duration / default recording) + term carry-forward. |
network-test.tsx · network-protocol.ts | LiveKit connectivity diagnostic + pure ICE-path classifier (getLiveKitReadiness() / classifyFromStats). |
types.ts · error-map.ts | Domain 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, pluscancelledandfailed.startLiveClassprovisions the SFU room (checksconferenceMaxConcurrent);endLiveClassis idempotent. The webhook is the authoritative writer forlive(room_started) andended(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 → S3me-central-1; per-school retention enforced by the daily cron. - Notifications (
actions/notifications.ts): all 5live_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.tscreate / reschedule / cancel) fan out. - Attendance (
actions/attendance-sync.ts, opt-in viaSchool.conferenceAttendanceSync): when a session ends (webhookroom_finishedor the stale-session cron),syncConferenceAttendancemarks each section student PRESENT/LATE from their in-room presence and ABSENT for roster non-joiners, writingAttendancerows with methodVIRTUAL. Requires asectionId+timetableId; LiveKit only — external links carry no presence data. Idempotent (section unique key, revive-on-update).
Routes
| Route | Layout | Renders | Roles |
|---|---|---|---|
/live | school-dashboard | landing/content.tsx — the landing page | all 7 school roles |
/live/dashboard | school-dashboard (app) | DataTable index | all 7 school roles |
/live/schedule | school-dashboard (app) | schedule-form.tsx | DEVELOPER · ADMIN · TEACHER |
/live/settings | school-dashboard (app) | settings-form.tsx + section-recording-policy.tsx | DEVELOPER · ADMIN |
/live/network-test | school-dashboard (app) | network-test.tsx | DEVELOPER · ADMIN (env-gated) |
/live/[id] | school-dashboard | detail.tsx | all 7 school roles |
/live/[id]/recordings | school-dashboard | recordings.tsx | all except ACCOUNTANT (academic, not finance) |
/live/[id]/room | (live-room) | room.tsx | any authenticated session participant |
/live-classes/* | — | redirect → /live | legacy 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).
| Model | Purpose |
|---|---|
Conference | A session. provider (livekit|external), meetingUrl?, visibility (section|school), catalogLessonId?. |
ConferenceParticipant | One row per invited user + connection telemetry (RTT, TCP-fallback flag). |
ConferenceRecording | Egress metadata + S3 location + retention expiresAt. |
ConferenceEvent | Webhook audit log + eventId @unique idempotency. |
ConferenceLink | Set-once recurring link [schoolId, subjectId, sectionId, termId]. |
ConferenceResource | Attached 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:
| Switch | Where | Means |
|---|---|---|
| Teach online | /live/settings | The standing "we are an online school" setting. |
| Online teaching by section | same page, per section | Overrides the school default in both directions. "School default" inherits. |
| Go online temporarily | same page, a date range | The 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
-
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.
-
For external links, set one link per class. Create a session from the wizard with Save as default, which stores a recurring
ConferenceLinkfor 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.
-
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.
-
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 link | In-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:
| Surface | Gate |
|---|---|
| Timetable Today rows (student · teacher · guardian) | in progress, or starting within 10 min (isRowLiveJoinable) |
| Timetable Current/Next card | the current class, or the next within 10 min (isLiveJoinable) |
/live landing "now" strip | live now + upcoming, section-scoped |
/live/dashboard table | any live or scheduled row the viewer can see |
| Parent portal → child → Timetable | a "Today" strip above the weekly table |
| Notification | started links straight to the room; the rest to the session detail |
| Mobile | live_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_BUCKETplus S3 credentials) and no longer blocks holding a call./live/network-testreports both. -
A configured SFU does nothing on its own —
School.conferenceProviderDefaultisexternaluntil 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_startedwebhook — 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.tsis 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-duereturned 500 while every page stayed green.binaryTargetsnow carries both Linux engines anddeploy-hobby.shrefuses 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
| Provider | Status | Missing |
|---|---|---|
| External link | ✅ Live | — (universal zero-infra default) |
| LiveKit SFU | 🟡 Coded, dormant | 6 infra gates → conference/RUNBOOK.md |
| Google Meet | 🟡 createMeeting wired, dark | Google Cloud OAuth creds (GOOGLE_MEET_*) |
| Zoom | 🟡 createMeeting wired, dark | Server-to-Server OAuth creds (ZOOM_*) |
| Microsoft Teams | 🟡 createMeeting wired, dark | Azure 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 theproviders/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
Teacherrow 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
ConferenceParticipantrow carriesjoinedAtonly once someone actually reached the room, and rows are created lazily at join time, so "no row with ajoinedAt" 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 areadyone 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
readyrecording qualifies, and only a reader withview_recordingsis offered the link. - Rename is DB-transparent.
@@map/@mapkeep 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.timetableIdanchors the teacher/student/guardian Start / Join buttons · Notifications — 5live_class_*types via the shared hub (in-app + email) · Attendance — opt-inVIRTUALmarks from in-room presence
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