---
url: https://docs.nemo12.com/architecture/sdd-040-live-sessions.md
description: >-
  Buổi học trực tiếp (SDD-040): địa chỉ, phòng học qua Zoom, check-in, phản tư
  4F và ván đấu trong live session.
---

# SDD-040 — Buổi học trực tiếp

Chỉ đạo chủ dự án 2026-09-20: *"Cần có trang mới, kiểu như Live Session (dành cho các buổi học mà học trực tiếp qua Zoom), rồi khu vực này giúp giáo viên quản lý xem ai đang trong buổi học. Và trạng thái của mỗi learner là như thế nào, họ đã học xong cái gì rồi, chưa học xong cái gì. Trong trang learn.nemo12.com/ielts, ở đâu đó có trang để vào học lớp học live, có chỗ để nhập code của lớp học. Trong đó, learner có thể điền các thông tin thông thường của một buổi học. Đầu buổi học cần trả lời câu hỏi gì đó dạng free text, vài câu khác dạng quiz, và có Mood Tracker, để Mentor biết cảm xúc, và cảm giác của Learner đối với việc học. Và rà soát sự thỏa mãn của Learner đối với mục tiêu và đối với tốc độ học. Mentor cần biết cái gì đang block learner, để cùng hỗ trợ. Hoặc cần biết là Learner đang hào hứng tới đâu. Cuối buổi cần có 4F Reflection. Có một game thi đấu trực tiếp kiểu Kahoot, thầy cô cho mọi người vào game, rồi cùng start game, sau mỗi câu thì có kết quả và bảng xếp hạng."*

## 1. Việc mà tài liệu này giải

Buổi học live hiện diễn ra trọn vẹn trong Zoom, và Nemo12 không biết gì về nó. Ba khoảng trống, xếp theo mức tốn kém:

1. **Mentor vào buổi mà không biết learner đang ở đâu.** Họ phải nhớ, hoặc mở cổng Dolphin ở một tab khác và tra từng người. Nên mười phút đầu buổi thường trôi vào việc hỏi lại những thứ hệ đã biết.
2. **Cảm giác của learner không được hỏi, nên không được biết.** "Mục tiêu này có còn đúng không", "tốc độ có chịu nổi không", "cái gì đang chặn con" là ba câu mà không ai hỏi trong một buổi sáu mươi phút chật kín nội dung — và chúng lại đúng là ba câu quyết định learner còn học tiếp hay không.
3. **Buổi học kết thúc là bốc hơi.** Không có dấu vết nào để buổi sau bắt đầu từ chỗ buổi trước dừng.

Ba khoảng trống ấy ứng với ba khối của tài liệu này: **phòng học** (§3), **check-in và phản tư** (§4), **ván đấu** (§5). Khối thứ ba trông như phần giải trí, nhưng nó cũng là khối trả lời câu hỏi "cả lớp có đang theo kịp không" trong thời gian thực — mười câu hỏi trả lời đồng thời là một phép đo mà không bài kiểm tra nào rẻ bằng.

**Ngoài phạm vi:** gọi video (Zoom vẫn là Zoom), điểm danh chính thức (REQ-MEN-04), và xếp lịch buổi (đã có ở [SDD-034](sdd-034-mentor-slots.md) và [SDD-031](sdd-031-calendar.md)). Buổi live ở đây **không** buộc vào một slot: mentor mở phòng khi cần, kể cả buổi dạy bù hẹn qua Zalo.

## 2. Địa chỉ

| Ai | URL | Trang |
| --- | --- | --- |
| Mentor | `dolphin.nemo12.com/live` | Danh sách buổi, nút mở phòng mới |
| Mentor | `dolphin.nemo12.com/live/{sessionId}` | Một buổi: ai đang trong phòng, điều khiển ván đấu |
| Learner | `learn.nemo12.com/ielts/live` | Ô nhập mã lớp |
| Learner | `learn.nemo12.com/ielts/live/{MÃ}` | Phòng học |

Địa chỉ phòng của learner mang **mã lớp**, không mang id nội bộ. Mã là thứ learner đang cầm trên tay, nên gõ thẳng URL vào thanh địa chỉ là vào được, và mentor dán được một link đầy đủ vào khung chat Zoom thay vì đọc mã ba lần. `POST /v1/live/join` là upsert nên mở lại link ấy bao nhiêu lần cũng chỉ là một người trong danh sách.

Lối vào của learner nằm ở hub `/ielts`, ngay trên bốn kỹ năng. Phần lớn ngày nó là một dòng vô dụng; nhưng vào cái ngày nó có việc thì learner đang vội và đang nghe mentor đọc mã, và họ mở đúng trang ấy.

## 3. Phòng học

**Mã lớp** là **bốn chữ số** (1000-9999) — chỉ đạo chủ dự án 2026-09-20 (SRC-906), thay cho sáu ký tự chữ-và-số của bản đầu.

Bản đầu bỏ hẳn `0 O 1 I L` cho khỏi nghe nhầm, nhưng bỏ những cặp ấy đi vẫn không bỏ được hai việc: mentor phải **đánh vần** từng chữ cái qua Zoom, và learner phải gõ chúng trên bàn phím điện thoại đang rất muốn sửa `ABC234` thành một từ có nghĩa. Bốn chữ số thì đọc thành tiếng một lần là xong, và ô nhập mở thẳng bàn phím số (`inputMode="numeric"`). Mã luôn đủ bốn chữ số: một mã `0421` đọc lên là "bốn hai mốt" và learner gõ thiếu một ký tự. Số lần thử lại khi trùng tăng từ 3 lên 8, vì 9000 tổ hợp vẫn thừa cho vài chục buổi mở cùng lúc nhưng không còn rộng tới mức ba lần thử là chắc chắn. Mã **không phải mật khẩu**: mọi người vào phòng đều phải có phiên đăng nhập và có hồ sơ learner, nên mã đoán trúng cũng không lấy được gì. Thứ mã chống là "vào nhầm phòng".

Một mã chỉ trỏ tới một phòng **chưa kết thúc** (unique index có mệnh đề `WHERE status <> 'ended'`), nên mã của tuần trước dùng lại được cho tuần này.

**Trạng thái buổi:** `lobby` → `live` → `ended`. Không có `draft`: một buổi tạo ra là để learner vào ngay, và một trạng thái mà learner chưa vào được thì mentor sẽ tạo buổi rồi quên bật.

**Có mặt** đo bằng `last_seen_at` chứ không bằng một cờ `online`: cờ chỉ đúng khi mọi người thoát bằng cách bấm nút, còn thực tế là họ đóng tab, mất wifi, hoặc sập nguồn. Quá 90 giây không thấy là không còn ngồi đó — gấp ba nhịp hỏi lại, nên một lần mất gói tin không đẩy ai ra khỏi danh sách.

**"Đã học xong cái gì, chưa xong cái gì"** đọc từ `readPathway` ([SDD-038](sdd-038/index.md)) qua một endpoint **riêng**, `/v1/live/sessions/{id}/progress`. Riêng vì nó là N+1 theo số người trong phòng, và mentor chỉ cần nó vài lần một buổi — nhét vào endpoint hỏi-lại ba giây một lượt là nhân cái giá ấy với ba mươi lượt mỗi phút.

**Buổi đã đóng vẫn mở lại được, cho đúng một người: người đã ngồi trong buổi ấy.** Vá một mâu
thuẫn có thật giữa hai đầu (Audit #015 D-3). Server CỐ Ý nhận phản tư 4F kể cả sau khi mentor bấm
kết thúc, vì mentor đóng buổi lúc 20:00 còn learner đang viết dở chặng Future. Nhưng đường DUY
NHẤT để màn learner lấy được `sessionId` là `join` bằng mã, mà `join` lọc `status <> 'ended'`. Nên
chỉ cần learner lỡ tay tải lại trang, hay điện thoại khoá màn rồi trình duyệt dựng lại tab, là
biểu mẫu 4F biến mất vĩnh viễn: khả năng server mở ra thì client khoá lại. Mà nhánh 4F chỉ hiện
khi buổi đã đóng, tức là nó chỉ tới tay learner đúng trong khoảng thời gian mà một lượt tải lại
trang xoá nó.

Hai giới hạn giữ cho đường này không thành một cái cửa mở: chỉ người **đã có mặt** mới vào lại
được (ai cầm mã mà chưa từng dự thì vẫn nhận đúng câu trả lời cũ, nên mã tuần trước không bày danh
sách lớp cho người lạ), và lượt mở lại **không** nhấc `last_seen_at` — viết nốt phản tư không phải
là "đang ở trong phòng", và nói ngược lại là nói dối bảng của mentor.

## 4. Check-in đầu buổi và phản tư 4F cuối buổi

Biểu mẫu do **server khai** (`modules/liveSession/checkin.ts`), cả hai app đọc. Hai bản chép của cùng bộ câu hỏi là hẹn ngày mentor đọc "Mood: 3" dưới một câu hỏi khác với câu learner đã trả lời.

Check-in gồm: một **Mood Tracker** năm mức có emoji, **bốn thang 1-5**, **hai ô chữ**, và **ba câu trắc nghiệm** mở màn.

| Thang | Câu hỏi | Trả lời câu nào của chỉ đạo |
| --- | --- | --- |
| `energy` | Còn bao nhiêu sức cho buổi này | "cảm xúc và cảm giác của Learner" |
| `excitement` | Hào hứng tới đâu với việc hôm nay | "Learner đang hào hứng tới đâu" |
| `goal_fit` | Mục tiêu IELTS có còn là mục tiêu đúng | "sự thoả mãn đối với mục tiêu" |
| `pace_fit` | Tốc độ học thấy thế nào | "sự thoả mãn đối với tốc độ học" |

`goal_fit` tách khỏi `pace_fit` vì hai câu trả lời dẫn tới hai việc khác nhau: mục tiêu sai thì phải ngồi lại đặt mục tiêu khác, tốc độ không chịu nổi thì giữ mục tiêu và giãn lịch. Gộp thành một thang "hài lòng" thì mentor đọc được một con số thấp mà không biết sửa cái nào.

Thang **1-5 chứ không 1-10**: learner trả lời trong bốn mươi giây, trên điện thoại. Mười mức thì khác biệt giữa 6 và 7 là do ngón tay chứ không do cảm giác.

Hai ô chữ là `focus_text` ("hôm nay con muốn gỡ được gì") và `blocker_text` ("cái gì đang cản con"). Ô thứ hai vẽ trên nền cảnh báo ở màn mentor — nó là câu duy nhất trên thẻ mà mentor phải làm gì đó với nó ngay trong buổi.

Ba câu trắc nghiệm **không chấm điểm và không vào hồ sơ**: việc của chúng là kéo learner từ trạng thái "vừa mở tab" sang "đang nghĩ về IELTS" trước khi mentor nói câu đầu tiên. Hai trong ba câu cố ý **không có đáp án đúng** (`answer: -1`) vì chúng hỏi về learner chứ không hỏi về IELTS.

**Phản tư 4F** (Greenaway) cuối buổi: Facts · Feelings · Findings · Future, bốn cột rời trong CSDL chứ không một khối JSON — bốn chặng là khung cố định, và bốn cột thì đếm được bằng SQL "bao nhiêu learner viết nổi chặng Future". Thứ tự không đổi được: nhảy thẳng vào "lần sau con làm gì" khi chưa kể lại chuyện gì đã xảy ra thì câu trả lời luôn là "con sẽ cố gắng hơn".

Phản tư **nộp được kể cả khi buổi đã đóng**: mentor bấm kết thúc lúc 20:00 còn learner đang viết dở chặng Future. Nộp đủ **một** chặng là gửi được — một biểu mẫu cuối buổi mà chặn đường ra thì thứ nó thu được là bốn ô điền cho xong.

**Câu quiz BỎ TRỐNG ghi là bỏ trống, không thành lựa chọn thứ nhất** (Audit #015 D-10). Phần quiz
của check-in không bắt buộc: điều kiện nộp chỉ đòi mood và các thang đo, nên bỏ trống là đường đi
bình thường chứ không phải ngoại lệ. Bản đầu gửi `?? 0` cho câu chưa bấm, và bản ghi nói learner
đã chọn "Listening" cho câu hỏi kỹ năng khó nhất trong khi họ chưa đụng vào nó — không phân biệt
được với người thật sự đã bấm ô ấy. Cột `quiz_json` tồn tại để giữ NGUYÊN câu trả lời, nên một câu
bịa ra làm hỏng đúng việc mà cột ấy sinh ra để làm.

Ký hiệu là **`-1`**, và nó không phải một con số chọn bừa: `QUIZ[0].answer` đã là `-1` cho câu
không có đáp án đúng, nên module này vốn đã đọc `-1` là "không có lựa chọn nào". Khuôn thân request
nhận `-1..9`; `-2` vẫn bị chặn, vì "chưa trả lời" là một giá trị có nghĩa chứ không phải một cái
cửa mở cho số rác.

## 5. Ván đấu

Mười câu IELTS cho người mới (`modules/liveSession/game.ts`, pack `beginner`). Không câu nào cần đọc một đoạn văn: trong một ván tính điểm theo giây, một câu cần đọc ba mươi giây chỉ đo tốc độ đọc.

**Nhịp do mentor giữ, không do đồng hồ.** Mentor mở câu, thấy "7/9 đã trả lời", rồi bấm hiện đáp án. Một ván mà câu tự đóng lúc còn hai người đang đọc đề là một ván dạy learner rằng đọc kỹ thì thiệt.

**Điểm:** 600 cho đúng + tối đa 400 cho tốc độ, tụt tuyến tính trong 25 giây. Hai phần chứ không phải một hàm tốc độ thuần — nếu tốc độ quyết định phần lớn điểm thì learner học được "đoán nhanh lợi hơn nghĩ", ngược với mọi thứ phòng thi dạy. Với tỉ lệ này, người trả lời đúng ở giây cuối **luôn** trên người trả lời sai ở giây đầu.

**Bốn cái chặn nằm ở server**, vì client là thứ learner sửa được bằng DevTools và một bảng xếp hạng là thứ đáng để ai đó thử:

* Đáp án và câu giải thích **chỉ rời server khi câu đã đóng**.
* Trả lời phải đúng câu đang mở.
* `UNIQUE(session_id, learner_id, q_index)` — luật "mỗi câu một lần" ghi vào schema, không vào tầng ứng dụng.
* **Đồng hồ cũng ở server.** Thân request KHÔNG mang số mili giây; server đo từ `question_started_at`
  của chính nó, cột này được ghi cùng lượt với `question_open`.

Cái chặn thứ tư thiếu mất trong bản đầu và được Audit #015 (D-2) tìm ra: ba cái trên đều ở server,
nhưng số mili giây thì vẫn nhận từ client rồi đem chấm thẳng. Gửi `ms: 0` là ăn trọn 400 điểm tốc
độ ở mọi câu, kể cả câu bấm ở giây thứ hai mươi tư. Nói cách khác, ba cái khoá cửa trước trong khi
cửa sau mở — và cửa sau ấy mở đúng vào phần điểm mà ván đấu dùng để xếp hạng.

**Và màn hình phải đọc cùng cái đồng hồ ấy.** Trạng thái phòng mang `elapsed_ms`: câu đã mở được
bao lâu, do server đo. Đây là một KHOẢNG chứ không phải một MỐC, và khác biệt ấy có lý do: nếu gửi
`started_at` rồi để client tự trừ bằng đồng hồ của nó thì một máy sai giờ vài phút sẽ hiện ra "hết
giờ" ngay từ giây đầu. Client neo con số ấy một lần mỗi câu rồi đếm tiếp bằng đồng hồ của mình, nên
nó vừa đúng với người vào muộn, vừa không giật theo độ trễ mạng.

Server cũng **không trả về đúng/sai** khi ghi nhận câu trả lời: cả lớp cùng biết đáp án một lúc. Ai nộp sớm mà biết ngay mình sai thì có thêm hai mươi giây để nhắc bạn bên cạnh.

Câu giải thích (`why`) hiện **cùng lúc** với đáp án. Một ván chỉ nói ai đúng ai sai thì hết buổi learner nhớ bảng xếp hạng chứ không nhớ gì về IELTS.

**Một phần hỏng không được khoá cả phòng.** Bộ câu hỏi của hai biểu mẫu (`/v1/live/form`) tải
riêng với trạng thái phòng, và cổng render chỉ đòi **trạng thái phòng**. Bản đầu đòi cả hai, trong
khi lượt gọi lấy bộ câu hỏi lại nuốt lỗi với chú thích "màn vẫn mở" — hai chỗ nói ngược nhau, và
kết quả là một lượt 500 hay một nhịp rớt sóng khoá luôn cả ván đấu, thứ chẳng dùng tới bộ câu hỏi
ấy (Audit #015 D-5). Learner ngồi nhìn "Opening the room..." suốt buổi trong khi cả lớp đang chơi:
không lỗi nào để biết, không nút nào để thử lại, và cách duy nhất là tải lại trang — mà tải lại
giữa buổi là bỏ lỡ câu đang mở.

Nay chỗ nào cần bộ câu hỏi mà chưa có thì **nói ra và cho bấm lại ngay tại chỗ**; phần còn lại của
phòng chạy bình thường.

## 6. Vì sao hỏi-lại chứ không WebSocket

Phòng học live là bài toán kinh điển của WebSocket, và Cloudflare có Durable Objects làm đúng việc ấy. Vẫn chọn hỏi-lại (mentor 3 giây, learner 2,5 giây):

1. **Quy mô thật là một lớp vài người.** Phòng mười người là 200 lượt đọc mỗi phút, nhỏ hơn thứ bàn theo dõi chịu mỗi sáng.
2. **Durable Object là hạ tầng mới** trong repo này: thêm một binding, một vòng đời, một chỗ nữa để hỏng lúc 8 giờ tối khi một lớp đang học. Hỏi-lại dùng đúng D1 và đúng worker đang chạy.
3. **Hỏi-lại hỏng nhẹ.** Mất mạng ba giây thì màn hình trễ ba giây rồi đuổi kịp; một lượt hỏi hỏng không xoá màn đang có. WebSocket đứt thì phải tự dựng lại trạng thái, và đó là phần code không ai kiểm được cho tới lúc nó cần chạy.

Ngày một lớp có trăm người cùng chơi thì đây là chỗ phải đổi, và thứ cần đổi chỉ là hai endpoint trạng thái.

## 7. Dữ liệu

Migration `0258_live_sessions.sql`: `live_sessions`, `live_participants`, `live_checkins`, `live_reflections`, `live_game_answers`. Trạng thái ván nằm **ngay trong** `live_sessions` (ba cột) chứ không ở bảng vòng riêng: một buổi có một ván, một ván có một câu đang mở, và tách ra thì mỗi lượt hỏi "đang câu mấy" — hỏi ba giây một lần — thành một phép JOIN.

## 8. Phân vai

| Endpoint | Ai gọi được |
| --- | --- |
| `/v1/live/sessions/**` | mentor · staff · admin |
| `/v1/live/room/**`, `/v1/live/join` | người có hồ sơ learner |
| `/v1/live/form` | ai đã đăng nhập |

Hai nhóm tách hẳn tiền tố chứ không chung một đường có cờ vai: một đường hai nghĩa là chỗ mà một lần quên kiểm vai biến thành một learner bấm được nút "sang câu tiếp". Quyền điều khiển buộc vào **vai**, không vào `mentor_user_id` — một mentor ốm thì người dạy thay phải mở được phòng ấy.

Màn mentor thấy **nguyên văn** check-in của cả lớp; learner chỉ thấy của chính mình, cộng bảng xếp hạng (tên và điểm, không có gì khác).

## 9. Trace

| Mục | REQ | Kiểm |
| --- | --- | --- |
| §3 phòng học, mã lớp, có mặt | REQ-MEN-18 | `modules/liveSession/routes.test.ts` — cổng vai, mã sai, mã của buổi đã đóng, vào hai lần là một người |
| §4 check-in + 4F | REQ-MEN-18 | cùng file — nguyên văn blocker tới tay mentor, nộp lại thì đè, thang ngoài 1-5 bị chặn, 4F nộp được sau khi buổi đóng |
| §5 ván đấu | REQ-MEN-18 | cùng file — đáp án không rò khi câu đang mở, mỗi người một lần, thứ tự bảng xếp hạng, hết câu thì kết thúc |
| §2 địa chỉ | REQ-MEN-18 | `apps/learn/src/ielts/liveRoutes.test.ts` — parse/href khớp nhau, không nuốt nhánh đang chạy |

## 9. Lịch buổi học, và cửa sổ mười phút (SRC-972, 22.09.2026)

> *"Learners cần nhìn thấy một số buổi học và thông tin giới thiệu chung về buổi đó. Các buổi chiều
> thứ 7, từ 15:00 tới 16:30 là buổi trao đổi về 'Cách đặt mục tiêu, cách học' (…) Learner có thể
> check-in trước buổi học 10 phút và sau khi kết thúc 10 phút (…) Link zoom sẵn sàng để lấy trước
> giờ bắt đầu đúng 10 phút."*

### 9a. LỊCH khác PHÒNG, nên khác bảng

`live_sessions` (§7) là **căn phòng**: ra đời lúc thầy cô bấm mở, mang mã bốn số, chết khi buổi
kết thúc. Nó không trả lời được câu learner hỏi ba ngày trước đó: *"tuần này có buổi nào, mấy giờ,
nói về cái gì"*.

`live_class_schedule` (migration 0272) là **lịch**: tồn tại trước buổi học và tồn tại tiếp sau khi
phòng đã đóng. Gộp hai thứ vào một bảng thì mọi câu về lịch phải lọc bỏ trạng thái phòng, và mọi
câu về phòng phải lọc bỏ buổi chưa tới. `live_sessions.schedule_id` nối hai bên, NULL với phòng mở
ngoài lịch.

Giờ lưu **kèm múi giờ** (`2026-09-26T15:00:00+07:00`): hệ chạy trên Cloudflare (UTC) còn learner
ngồi ở Việt Nam, và một chuỗi giờ không mang múi sẽ được đọc là UTC ở tầng nào đó — buổi 15:00
chiều thành 22:00 tối trên màn hình của chính người sắp vào học.

### 9b. Luật mười phút nằm ở SERVER

| | Mở | Đóng |
| --- | --- | --- |
| Link Zoom | 10 phút trước giờ bắt đầu | 10 phút sau giờ tan |
| Check-in | như trên | như trên |

Hai cửa sổ trùng nhau hôm nay nhưng là **hai hằng số riêng** (`OPEN_BEFORE_MIN`,
`CLOSE_AFTER_MIN`): một buổi có thể cho vào muộn mà không cho check-in muộn, và khi ấy chỉ một
trong hai đổi.

Nếu server cứ trả `zoom_url` rồi để client tự giấu tới giờ, cái link ấy đã nằm trong phản hồi mạng
từ lúc learner mở trang lần đầu — cả tuần trước buổi học. Server trả `null` cho tới đúng phút, kèm
`zoom_available_at` để màn hình nói được "link mở lúc mấy giờ" mà không cần chính cái link.

**Buổi đã huỷ không bao giờ trả link**, kể cả đang đúng giờ: một learner bấm vào đó sẽ ngồi một
mình trong phòng Zoom và nghĩ mình tới nhầm giờ.

### 9c. Hàm thuần, đồng hồ neo

`viewOf(row, now)` nhận `now` từ ngoài. Đọc `new Date()` bên trong thì ca kiểm sẽ xanh hay đỏ tuỳ
vào lúc trong ngày mà CI chạy — và một ca gác luật an toàn mà kết quả phụ thuộc giờ chạy thì không
gác gì cả. Chín ca ở `liveClasses.test.ts`, gồm cả hai mốc sát biên (14:49 và 14:50).

### 9d. Buổi đã qua vẫn hiện

Danh sách trả ba buổi gần nhất đã qua cộng tám buổi sắp tới. Learner mở trang hôm sau muốn xem lại
buổi hôm qua nói về gì, và một danh sách chỉ có tương lai thì hôm sau buổi ấy biến mất như chưa
từng có.

Tám buổi thứ Bảy đầu tiên seed sẵn trong migration, `INSERT OR IGNORE` với id theo ngày nên chạy
lại không sinh bản sao.

### 9e. Link Zoom chỉ cho thành viên IELTS trả phí (SRC-1210, 03.10.2026)

Chỉ đạo chủ dự án: link Zoom chỉ cho người có membership IELTS. Audit 03.10.2026 tìm ra rằng cả
hai route lịch chỉ đòi đăng nhập, nên mọi tài khoản - kể cả bố mẹ hay một tài khoản lạ - nhận link
trong cửa sổ mười phút.

* **Ai nhận link:** learner có `learner_ielts_membership.plan = 'member'` còn hạn (theo ngày Việt
  Nam), cộng mentor và admin - người dạy và người vận hành lớp. Người đang **dùng thử không
  tính** (chủ dự án chọn 03.10.2026).
* **Lịch vẫn mở cho mọi tài khoản đăng nhập.** Giờ học không phải bí mật; thứ cần giữ là cái phòng.
* Server trả `zoom_url: null` kèm `members_only: true`, và màn hình mời xem gói thành viên thay vì
  nói "link mở lúc mấy giờ" - một lời hứa sẽ không bao giờ được giữ.
* Kiểm quyền bằng `isPaidIeltsMember`, hàm **chỉ đọc**: `readMembership` tạo dòng dùng thử ở lượt
  đọc đầu, và một lượt mở trang Live không được là lúc đồng hồ dùng thử bắt đầu chạy.
* Quyền nằm ở ROUTE (`canJoinLive`), `viewOf` chỉ nhận cờ `canJoin`. Test route
  `liveClassesRoutes.test.ts` gác năm vai qua request thật, vì test của hàm thuần vẫn xanh khi
  route quên hỏi quyền.
