SDD-033 — Tài liệu đọc trong bài học
Chỉ đạo chủ dự án 2026-09-08: "Trong mọi lesson trong các khóa học, cần có một hoặc vài tài liệu, dạng như của Substack, có thể dùng TipTap format, có thể sửa nội dung, soạn trong phần admin, có thể nhúng ảnh hay video vào. Để tài liệu đó thật thú vị."
1. Vì sao không dùng bảng học liệu đã có
curriculum_lesson_materials (migration 0085) trỏ ra ngoài: một link YouTube, một PDF, một lab. Ràng buộc CHECK của nó nói thẳng điều đó, mọi kind đều đòi url hoặc ref_id. Nó không có chỗ chứa nội dung, không có bản nháp, không có lịch sử.
Tài liệu ở đây là nội dung tự soạn nằm trong D1. Nhét nó vào bảng cũ thì phải nới CHECK và thêm sáu cột mà chỉ một kind dùng tới, tức là phá chính cái ràng buộc đang giữ cho bảng ấy đúng. Nên đây là bảng riêng: curriculum_lesson_articles (migration 0209).
Hai thứ vẫn đứng cạnh nhau trên trang học, và thứ tự có lý do ở §6.
2. Bảng
curriculum_lesson_articles — khoá (lesson_id, slug), cộng curriculum_lesson_article_versions cho lịch sử.
slug chỉ nhận [a-z0-9-]. Không phải chuyện thẩm mỹ: nó vừa nằm trên URL vừa là một nửa của khoá tiến độ connect:article-<slug>, mà route parts chặn đúng bộ ký tự ấy ở tầng schema (migration 0193). Một slug lọt ký tự lạ thì cụm ấy im lặng không bao giờ đánh dấu xong được, và learner đọc xong vẫn thấy bài chưa hoàn thành mà không hiểu vì sao. Tiêu đề tiếng Việt được bỏ dấu bằng slugify; trùng thì nối số chứ không đè lên bài của người khác.
Hai cột cho một nội dung (doc_* và html_*). doc là TipTap JSON, thứ trình soạn mở ra để sửa, và là nguồn chuẩn. html là bản render đã lọc, thứ trang học đọc, sinh từ doc ở server mỗi lần lưu. Lý do ở §3.
status. Bản nháp không tới tay learner. Soạn dở một bài đọc là chuyện của nhiều buổi; không có nháp thì người soạn buộc phải viết một mạch hoặc để learner nhìn thấy bài viết dở. published_at giữ lần đăng đầu tiên, rút rồi đăng lại không đặt lại nó.
Lịch sử phiên bản. Một bài đọc là công sức nhiều buổi, và trình soạn nào cũng có ngày nuốt mất một đoạn: dán đè, chọn nhầm rồi gõ tiếp, một lần Ctrl+A không cố ý. Sổ ghi một dòng cho mỗi lần lưu. Chỉ ghi doc, không ghi html: html sinh lại được bất cứ lúc nào, doc thì không sinh lại được từ đâu cả.
3. Render ở server, một bộ lọc cho cả hệ
workers/api/src/shared/richtext.ts biến doc thành HTML. Đây là chỗ duy nhất trong hệ làm việc đó.
Render ở client thì apps/learn phải bundle cả TipTap chỉ để đọc, tức là kéo một trình soạn thảo vào máy learner để làm việc mà một chuỗi HTML làm được. Quan trọng hơn: mỗi app đọc mà tự lọc lấy là mỗi app một bộ lọc, và bộ yếu nhất quyết định mức an toàn của cả hệ.
Danh sách cho phép, không phải danh sách cấm. Bộ lọc kiểu "xoá thẻ script" luôn thua: onerror, javascript:, <svg><animate>, thẻ mới của HTML sang năm. Ở đây mọi nút và mọi mark không có tên trong danh sách đều bị bỏ, kể cả khi TipTap phiên bản sau sinh ra nút mới. Nút lạ mất thẻ bọc nhưng giữ chữ bên trong, nên một bản nâng cấp làm bài mất định dạng chứ không mất nội dung.
Bốn chốt đáng gọi tên:
- Link: chỉ
http,https,mailtovà đường dẫn bắt đầu bằng một dấu/. Ký tự điều khiển bị xoá trước khi so, vìjava<TAB>script:alert(1)là chuỗi trình duyệt vẫn chạy còn phép so sánh ngây thơ thì không thấy. Link hỏng thì mất link chứ không mất chữ. - Video: hai nhà cung cấp, và địa chỉ
iframedo hệ dựng từ id. Nhận URL nhúng nguyên văn là mở một khung chạy trang lạ bên trong trang học. - Ảnh: chỉ mở từ kho ảnh của hệ qua
mediaId. Ảnh trỏ ra ngoài bị bỏ hẳn, vì nó vừa là một ảnh có ngày chết vừa là một đường báo cho máy chủ lạ biết ai vừa mở bài nào. - URL ảnh tuyệt đối. HTML này sinh ở
api.nemo12.comnhưng được đọc ởlearn.nemo12.com; một đường dẫn tương đối trỏ vào một máy chủ không có tấm ảnh nào. Đây là kiểu hỏng chỉ lộ ra trên production, vì lúc chạy dev mọi thứ cùng một cổng.
Không route nào nhận html. Đường ghi duy nhất dựng lại nó từ doc.
4. Đường API
Đọc (learner), trong curriculum/routes.ts:
- Tóm tắt các bài đã đăng đi kèm payload course (
lesson.articles), không có thân bài. IELTS Core có 201 lesson; gửi kèm HTML của mọi bài đọc là gửi vài megabyte cho người sắp mở đúng một bài. GET /v1/curriculum/lessons/{id}/articles/{slug}trả thân bài, gọi khi learner mở đúng bài ấy.
Route đó đi qua đúng bộ ba cổng mà mọi route lesson khác đã đi qua, và vì đúng những lý do ấy (SDD-032 §5): phiên đăng nhập, requireLearnerAccess, rồi canOpenLesson. Bài đọc là nội dung dạy chứ không phải mục lục; thiếu cổng thứ ba thì đổi một learner_id trên URL là đọc được tài liệu của cả khoá chưa mua.
Chưa đăng thì 404, không phải 403. Với learner, một bản nháp KHÔNG TỒN TẠI; nói "có bài này nhưng chưa cho xem" là tiết lộ lịch soạn bài và mời người ta quay lại thử.
lockedShell (SDD-032 §3) cắt cả tên bài đọc, không chỉ thân bài. Thân bài vốn không đi trong payload course, nhưng để lại danh sách tiêu đề thì trang vẽ ra một cụm bấm vào chỉ nhận 403, và learner chưa mua đọc được đúng thứ hấp dẫn nhất của bài: cái tên.
Soạn (admin), trong curriculum/articleRoutes.ts dưới /v1/admin/curriculum/*. Router tách riêng vì hai bên khác nhau ở gốc: bên learner mọi route hỏi "learner nào, có được mở bài không", bên này hỏi đúng một câu "có vai admin hay staff không" và không chạm một dòng dữ liệu learner nào. Trộn vào một tệp là đặt hai mô hình quyền cạnh nhau, và đó là cách một handler ngày nào đó thừa hưởng nhầm cổng của hàng xóm.
Cổng nội dung chắn lúc ĐĂNG, không chắn lúc lưu. Lưu là việc của người đang viết dở; một cổng chặn lúc lưu sẽ chặn đúng lúc bài chưa xong. Ba luật khi đăng: có tên, thân bài đủ dài, không em dash (SRC-379). Chiều rút xuống nháp thì không có cổng nào: đường gỡ một bài hỏng khỏi mắt learner không được đi qua việc sửa cho nó hết hỏng trước.
5. Trình soạn
apps/admin tab Tài liệu bài học. Ba cột theo đúng thứ tự câu hỏi của người soạn: bài nào → tài liệu nào → viết. Cây curriculum hiện đã đăng/tổng ngay cạnh mỗi bài, vì thứ người soạn cần nhất khi mở màn này lên là bài nào còn trống.
TipTap ghim 3.31.3. Nó không phải thư viện UI thứ tư (DS-001 §0): nó là một trình soạn văn bản, không mang theo token màu, component hay hệ animation nào. Thanh công cụ là nút Tailwind thường như mọi màn admin khác. Trang được lazy import: TipTap là gói lớn nhất trong app, và phần lớn phiên admin không mở tới trang soạn bài.
Bốn nút tự định nghĩa (callout, details, embed, math) cùng một nút image riêng, trong apps/admin/src/editor/nodes.ts. Tự định nghĩa thay vì kéo thêm gói vì mỗi nút phải khớp chính xác với bộ render bên server; một nút từ gói ngoài mang theo tên và bộ thuộc tính của người khác đặt, và ngày họ đổi nó thì bài đã lưu trong D1 render ra khoảng trắng.
Ảnh kéo thả và dán được, và đi thẳng lên R2 qua POST /v1/showcase/media — cùng bảng media, cùng kho, cùng route phục vụ /v1/media/{id} của SDD-019 §2. Không dựng kho ảnh thứ hai: hai kho là hai chỗ phải nhớ dọn khi xoá và hai bộ luật kiểm duyệt. Ảnh dán không nằm lại dưới dạng data URI trong doc, vì một bài vài ảnh dán kiểu ấy nặng hàng megabyte trong D1.
Lưu là hành động có nút, không tự lưu theo từng phím: một lượt PUT cho mỗi ký tự vừa đổ rác vào sổ phiên bản vừa làm chậm D1. Bù lại, rời trang khi còn thay đổi chưa lưu thì trình duyệt hỏi lại.
Nút Xem như learner hiện đúng chuỗi html server đã dựng, không dựng lại ở client: thứ người soạn nhìn thấy phải là thứ learner nhận, kể cả khi bộ lọc đã cắt mất một khối.
6. Chỗ đứng trên trang học
Bài đọc là cụm trong chặng Kết nối, không phải chặng thứ năm. Chặng Kết nối vốn là nơi learner gặp cái vướng rồi tìm hiểu về nó; một chặng mới chỉ để chứa bài đọc là chia lại bài học vì lý do kỹ thuật.
Thứ tự trong chặng: câu chuyện → bài đọc → học liệu. Câu chuyện đặt ra cái vướng, bài đọc là chỗ Nemo12 giải thích nó bằng chữ của mình, học liệu là thứ mượn của người khác.
Mỗi bài đọc một mục menu, mang chính tên nó — không gộp thành một mục "Tài liệu". Tên bài đọc là thứ mời người ta mở nó ra; mỗi bài có URL riêng nên gửi được cho bạn và quay lại đúng chỗ; và khoá "đã đọc" trong D1 vốn đã theo từng bài, nên gộp menu là mất luôn khả năng nói bài nào đã đọc.
Tiến độ dùng lại curriculum_part_completions đã có (migration 0193) với khoá connect:article-<slug>, không dựng cơ chế đánh dấu thứ hai.
Hình thức nằm ở packages/design-system/prose.css, một tệp cho cả hai app. Trình soạn ở admin và trang đọc ở learn hiển thị cùng một chuỗi HTML; hai bảng style là hai cách trình bày cho một bài viết, và người soạn sẽ canh chỉnh theo thứ họ nhìn thấy chứ không theo thứ learner nhận được. Cột chữ hẹp 68ch: luật full-width (SRC-048) nói về bố cục trang, không nói về cột chữ, và một đoạn văn kéo hết màn 27 inch thì không ai đọc hết.
7. Điều đợt này KHÔNG làm
- Không có bản dịch tiếng Anh trong trình soạn. Cột
doc_en/html_enđã mở và đường đọc đã lùi về tiếng Việt khi thiếu (tx), nhưng màn soạn đợt này chỉ có ô tiếng Việt. Mở hai khung soạn song song khi chưa ai soạn bài tiếng Anh nào là dựng một nửa giao diện cho một việc chưa bắt đầu. - Không có xưởng sinh bằng model. Bài đọc là chỗ giọng của Nemo12 phải nghe ra được; sinh máy hàng loạt ở đây đi ngược đúng chữ "thật thú vị" trong chỉ đạo.
- Không có bình luận hay đánh dấu của learner trên bài đọc. Chưa có chỉ đạo nào về việc đó.
8. Trace
| REQ | Mục |
|---|---|
| REQ-ART-01 | §1, §2 |
| REQ-ART-02 | §3 |
| REQ-ART-03 | §4 |
| REQ-ART-04 | §4 |
| REQ-ART-05 | §5 |
| REQ-ART-06 | §6 |