---
url: https://docs.nemo12.com/architecture/sdd-032-curriculum-access.md
description: >-
  Quyền học curriculum (SDD-032): luật học thử lesson đầu, cắt nội dung ở server
  thay vì làm mờ ở giao diện.
---

# SDD-032 — Quyền học curriculum: học thử lesson đầu

Chỉ đạo chủ dự án 2026-09-05: *"Các khóa đều cho phép học thử một lesson đầu tiên."*

## 1. Điều đợt này thật sự làm

Câu chỉ đạo nghe như nới một cửa. Thực tế ngược lại.

Trước đợt này **curriculum không có cửa nào**. Mọi route lesson chỉ hỏi một câu — "tài khoản này có quyền trên learner đó không" (`requireLearnerAccess`) — và không hỏi câu thứ hai: "learner đó có được học khoá này không". Không có bảng quyền, không có cột `is_free`, không có nhánh nào phân biệt bài trả tiền với bài miễn phí. Một learner bất kỳ đọc được cả 201 lesson của IELTS Core.

Nên việc phải làm là **dựng cái khoá, rồi chừa lesson đầu ra** — không phải mở thêm một lối.

## 2. Luật học thử

Mỗi course mở đúng một lesson: **bài `seq` nhỏ nhất của unit `seq` nhỏ nhất**.

Luật đọc theo `seq`, không theo thứ tự mảng mà truy vấn trả về. `getCourse` có `ORDER BY`, nhưng một luật tính tiền không nên phụ thuộc vào mệnh đề `ORDER BY` nằm ở tệp khác — đổi nó là đổi cái miễn phí mà không ai nhận ra. Course chưa có lesson nào thì `trial_lesson_id = null`, và khi ấy không có gì mở.

Bài học thử mở **trọn vẹn**: đủ bốn chặng, học liệu, bài kiểm, bài ngẫm, ghi tiến độ bình thường. Một bài thử bị cắt xén không trả lời được câu hỏi mà người học thử đang hỏi.

## 3. Cắt nội dung ở server, không làm mờ ở giao diện

`getCourse` trả **toàn bộ nội dung của mọi lesson trong một payload**. Nếu chỉ khoá ở giao diện thì nội dung vẫn nằm nguyên trong JSON: mở tab Network là đọc hết cả khoá. Một cái khoá mà dữ liệu vẫn gửi đi thì không phải khoá, nó là lời đề nghị.

`redactCourse` (`workers/api/src/modules/curriculum/access.ts`) cắt trước khi dữ liệu rời server. Lesson bị khoá còn lại đúng phần **mục lục**: `id`, `seq`, tên bài, câu hỏi dẫn dắt, kèm cờ `locked: true`. Bị cắt: học liệu, bài kiểm, câu chuyện, hiểu lầm, lỗi hay mắc, can thiệp, outcome, `concepts_vi`, và cả **Performance Task của unit** — đó là đề bài đầy đủ, gửi đi là cho không phần đắt nhất.

Mục lục giữ lại là **có chủ ý**: người đang cân nhắc trả tiền cần thấy mình sắp mua gì, và bốn thứ đó đã công khai ở `/v1/public/ielts/catalog` cho ielts.nemo12.com từ trước. Giấu nốt chúng thì vừa khoá chặt hơn chỗ đang mở, vừa biến trang khoá học thành một danh sách trống.

`learner_id` là **tuỳ chọn** ở `GET /v1/curriculum/courses/{id}` — trang khoá học phải mở được cho người chưa chọn con nào. Thiếu nó nghĩa là **chưa có quyền**, không phải "bỏ qua kiểm tra".

## 4. `curriculum_entitlements`

Bảng ở migration `0202_trial_lesson.sql`. Khoá duy nhất `(learner_id, course_id)`.

Quyền gắn với **learner**, không phải user: một phụ huynh có nhiều con, và tiền trả cho đứa này không phải quyền học của đứa kia. `granted_to_user_id` giữ lại ai đã trả, để đối soát.

`source` phân biệt `purchase` (qua cổng thanh toán) với `grant` (admin cấp tay: học bổng, đền bù, dạy thử kéo dài) — ngày đối soát doanh thu sẽ chỉ đếm `purchase`.

Không có cột `expires_at`: chưa có luật hết hạn nào được chốt, và một cột không ai đọc là một lời hứa sai.

**Thiếu bảng ≠ cho qua.** Bảng chưa tồn tại (code deploy trước migration là nếp bình thường ở repo này) thì `hasEntitlement` trả `false`, tức mọi người vẫn học thử được và không ai mất tiền oan. Hiểu ngược lại là mở toang cả khoá vì một bảng chưa kịp tạo.

## 5. `LESSON_LOCKED` — và vì sao nó là 403

Khoá nội dung mà vẫn cho ghi tiến độ lên bài đang khoá thì dữ liệu tiến độ **nói dối**: nó bảo learner đã học một bài chưa từng được gửi tới máy họ. Nên các route chặng · cụm · bài ngẫm đi qua chốt thứ hai `canOpenLesson`.

Mã lỗi mới `LESSON_LOCKED` → **403**, là ngoại lệ duy nhất của luật "401 chứ không 403" trong [docs/reference/errors.md](../reference/errors.md). Luật đó tồn tại để 403 không tiết lộ rằng một tài nguyên có thật — với dữ liệu trẻ em đó đã là rò rỉ. Ở đây không có gì để rò: sự tồn tại của mọi bài học đã công khai trong catalog. Đổi lại, trả 401 gây hại thật — client hiểu là phiên hết hạn ([apps/learn/src/api.ts](../../apps/learn/src/api.ts)), bảo learner đăng nhập lại, và họ làm lại rồi vẫn không mở được bài.

Giao diện đọc cờ `locked` chứ **không** suy từ mảng rỗng: một bài soạn dở cũng có mảng rỗng, và hai chuyện đó phải nói khác nhau với learner. Bài bị khoá đi đường riêng trước `LessonView` — để nguyên thì learner thấy một bài trống trông y hệt một bài chưa soạn.

## 6. Tự mở hồ sơ học thử

`POST /v1/learners/self` tạo learner cho chính tài khoản đang đăng nhập, `status='active'`, `profile_source='self'`.

Trước đó câu "học sinh tự login rồi tự tìm hiểu khoá học" chưa đúng: đăng nhập Google thì được, nhưng tài khoản mới sinh ra là **owner của một family** (đường dành cho phụ huynh), còn learner thì phải có mã mời — nên người tự tìm tới chỉ gặp màn "nhập mã mời".

Hai đường tồn tại **song song**, không thay nhau: bố mẹ lập hồ sơ cho con nhỏ (hồ sơ gắn sẵn gia đình, người theo dõi, mục tiêu) và học sinh lớn tự tìm tới. Trên màn `Join`, lối tự mở nằm **dưới** ô mã mời — ai đang cầm mã thì mã vẫn là đường đúng.

Gọi lại lần nữa với tài khoản đã là learner thì trả về chính hồ sơ cũ, không ném 409: người dùng bấm hai lần là chuyện thường, và ở đây "đã có" chính là kết quả mong muốn.

Route này **không cấp quyền học**. Learner tạo ra ở đây học được đúng phần học thử như mọi learner khác.

## 7. Chưa làm: cổng thanh toán

`source='purchase'` và `order_ref` đã có chỗ trong bảng, nhưng **chưa có cổng thanh toán nào được nối** — repo không có hạ tầng thanh toán, và việc chọn nhà cung cấp (VNPay · MoMo · Stripe) cùng merchant credentials là quyết định của chủ dự án.

Tới khi đó, đường mở khoá thật là `source='grant'` cấp tay. Màn hình bài bị khoá vì vậy **không dựng nút "Ghi danh"** — một nút bấm vào không đi đâu làm learner tưởng mình vừa làm sai; nó nói bằng chữ để gia đình liên hệ.

## Trace

| REQ | Section |
| --- | --- |
| REQ-TRI-01 | §2 |
| REQ-TRI-02 | §3 |
| REQ-TRI-03 | §4 |
| REQ-TRI-04 | §5 |
| REQ-TRI-05 | §6 |
