Skip to content

Cộng đồng ​

Người học ở tokyo.conan.school không đứng trong một trường chung. Họ đứng trong một cộng đồng, và mỗi cộng đồng có khoá học riêng, Books riêng, thư viện tài liệu riêng, sự kiện riêng, thảo luận riêng.

Hai chiều, 18 cộng đồng, không phải 80 ô ​

ChiềuCâu hỏi nó trả lờiSố cộng đồng
IndustryBạn đang kinh doanh ở đâu9
OccupationBạn đang đóng vai trò gì9

Industry, 🍜 Food · ☕ Drink · 💄 Beauty · 🎓 Education · 📦 E-Commerce · 🏪 Retail · 🏠 Home · 🏃 Health & Fitness · 🏨 Hospitality & Travel

Occupation, 👑 Founder / CEO · 📈 Sales · 📣 Marketing · 🧠 Product · ⚙️ Operations · 👥 People / HR · 💰 Finance · 🤝 Customer Success · 🤖 AI / Technology

Sắp xếp lại 09.09.2026 (migration 20260919e_communities_restructure.sql)

Founder + CEO / Executive gộp làm một (cm-occ-founder, tên hiển thị "Founder / CEO"): ở quy mô người học của Tokyo cùng một người làm cả hai việc, và hai phòng cạnh nhau chia đôi số người trong mỗi phòng. Commerce tách đôi: bản đang có đổi tên thành E-Commerce (bán online), và dựng thêm Retail cho bán lẻ tại cửa hàng.

slug KHÔNG đổi (founder, commerce) dù tên hiển thị đổi: chất liệu file-based (content/jtbd/*.json, content/bmc/*.json) tra theo slug cộng đồng, không theo id, đổi slug là mọi Set JTBD và mọi canvas của cộng đồng đó biến mất khỏi giao diện mà không có lỗi nào.

cm-occ-ceo-executive ẩn chứ không xoá: đơn hàng cũ còn tham chiếu tới id đó. Người, đơn, nội dung, thư viện và JTBD Set của nó đã chuyển sang Founder / CEO, cờ "cộng đồng nhà" phải hạ ở dòng cũ TRƯỚC khi dựng ở dòng mới, vì chỉ mục duy nhất một phần chỉ cho mỗi người đúng một nhà. Retail bắt đầu rỗng và chưa có file BMC/JTBD; không chép nội dung của E-Commerce sang, vì nội dung đó viết cho người bán online.

Đây KHÔNG phải ma trận 8×10

Mười tám cộng đồng ngang hàng. dimension chỉ là nhãn để xếp chúng thành hai cột trên màn hình chọn. Không có cộng đồng "Food × Operations" nào được sinh ra tự động, một ô sinh tự động là một ô không ai soạn nội dung cho nó, và nó sẽ rỗng.

Taxonomy chọn theo loại hình kinh doanh, không theo nền tảng: Shopee Seller → Commerce, TikTok Seller → Commerce, Coffee Shop → Drink. Nền tảng chết sau vài năm; loại hình thì không.

Food tách khỏi Drink vì mô hình kinh doanh, hành vi khách và cách vận hành khác nhau rõ. Cùng lý do ấy tách E-Commerce khỏi Retail: bán trên sàn và bán trong cửa hàng khác nhau ở tồn kho, ở chi phí kéo khách, và ở cách đo một ngày bán được bao nhiêu.

Free và Pro ​

Chọn cộng đồng là bắt buộc ngay sau đăng nhập, và nó không bán gì cả: chọn xong vào xem ngay. Người dùng quay về /communities lúc nào cũng được để sang cộng đồng khác, đi lại giữa 18 cộng đồng ở mức free là tự do.

FreePro
Danh mục khoá / Book / tài liệu / sự kiệnThấy đầy đủThấy đầy đủ
Khoá họcBài đầu tiên của unit đầu tiênToàn bộ
DeepBookSection đầu của chapter đầuToàn bộ
Tài liệu thư việnChỉ cái đánh dấu xem thửToàn bộ
Thảo luậnThấy tiêu đềĐọc và đăng bài

Pro bán theo từng cộng đồng, theo năm. Một người là Pro của nhiều cộng đồng thì trả nhiều lần, mỗi cộng đồng một đơn, một hạn riêng.

Giá: một quy tắc, không phải một con số ​

Cả 18 cộng đồng cùng một giá: 9 triệu/năm trong tháng 9.2026, tăng 1 triệu vào ngày 1 mỗi tháng.

ThángGiá
9.20269 triệu
10.202610 triệu
12.202612 triệu
9.202721 triệu

Quy tắc nằm ở communities.pricing_json, cùng hình dạng với membership_plans.pricing_json, và giá hiện hành do computePrice() tính, cùng hàm mà gói thành viên dùng. Không có cột nào lưu con số giá.

Vì sao không lưu con số

Lưu 9000000 nghĩa là mỗi đầu tháng phải có người nhớ sửa 18 dòng. Cái ngày không ai nhớ, hệ vẫn bán trơn tru ở giá tháng trước, mãi mãi, không màn hình nào đỏ, không người dùng nào than. Thứ duy nhất biết là bảng doanh thu, vài tháng sau.

Bẫy múi giờ, đã xảy ra thật (03.09.2026)

computePrice() đọc tháng bằng getMonth(), tức múi giờ của tiến trình. Worker chạy UTC, nên đưa new Date() vào thẳng thì giá nhảy bậc lúc 7 giờ sáng ngày 1 giờ Việt Nam: bảy tiếng đầu mỗi tháng bán ở giá tháng trước.

Tệ hơn: máy dev ở UTC+7 cho kết quả khác Worker, nên thử ở máy không bao giờ thấy.

vietnamCivilMonth() trong communities/pricing.ts chốt tháng dương lịch Việt Nam trước khi đưa vào công thức, và trả về ngày 15 lúc 12:00 UTC để mọi múi giờ đọc ra cùng một tháng. Phép kiểm api.conan.school/tests/community-pricing.test.mts chạy mọi mốc ở ba múi giờ, npm run test:pricing.

Đơn KHÔNG giữ giá, và server phải kiểm ​

Bấm mua tháng 9, trả tiền tháng 6 sang năm, vẫn 9 triệu, đó là lỗ hổng nếu không ai kiểm. Hai hàng rào, cả hai ở server:

ChỗLuật
POST /:slug/ordersĐơn chờ chỉ được dùng lại khi số tiền còn khớp chính sách. Lệch thì huỷ và tạo đơn mới ở giá hôm nay.
POST /admin/communities/orders/:id/approveTừ chối nếu amount_vnd khác giá chính sách hiện hành. Trả PRICE_POLICY_MISMATCH kèm cả hai con số.

Thước là checkAgainstPolicy(), không phải "cùng tháng tạo đơn". Khác biệt đó có thật: admin đổi giá nền giữa tháng cũng làm đơn chờ thành cũ ngay, không đợi tới ngày 1. Dùng chính sách làm thước thì cả hai trường hợp đi cùng một đường, và không có trường hợp thứ ba lọt qua.

Vì sao hàng rào phải ở lượt DUYỆT

Lượt duyệt là chỗ duy nhất trong hệ mà một con số tiền biến thành một năm quyền truy cập. Không có hàng rào ở đó thì mỗi đơn nằm chờ qua ngày 1 là một năm Pro bán dưới giá, và không có gì báo: đơn hiện "đã duyệt", quyền hiện "còn hạn", bảng doanh thu cộng đúng số tiền đã thu. Chỉ tổng cuối kỳ là thiếu, và lúc đó không truy được thiếu ở đâu.

Số tiền không bao giờ đến từ client: POST /:slug/orders không đọc thân request, giá tính ở server. Đừng thêm tham số amount vào đó, hàng rào ở lượt duyệt sẽ bắt được, nhưng đừng để nó phải bắt.

Trang quản trị hiện chỗ lệch trước khi bấm: số tiền tô màu lỗi, giá chính sách hiện ngay dưới, nút duyệt bị khoá. Một cái nút bấm được rồi mới báo lỗi là một cái nút dạy người ta bấm thử.

Admin sửa giá nền; mốc (base_year/base_month) chỉ đổi được bằng migration, vì đổi mốc là viết lại lịch sử giá. Trang quản trị hiện giá tháng này cạnh giá tháng sau: hai con số bằng nhau nghĩa là monthly_increment đã mất, và không có gì khác báo chuyện đó.

Hai hằng số nếm thử nằm ở PREVIEW trong api.conan.school/src/domains/communities/access.ts. Muốn nới thì nới ở đó, đừng sửa rải trong handler.

Bảng dữ liệu ​

BảngVai trò
communities18 dòng sản phẩm + 1 dòng chứa tạm. pricing_json giữ quy tắc giá ({} = chưa mở bán). access = pro hoặc open.
community_membershipsTư cách của một người ở một cộng đồng. is_home đánh dấu cộng đồng "nhà".
community_ordersĐơn mua Pro. Có TRƯỚC cổng thanh toán, cố ý.
community_library_docs · community_collections · community_posts · community_post_repliesNội dung riêng của cộng đồng.
community_content_linksHiển thị chéo. Chưa dùng. Không bao giờ dùng để gác quyền.

Nội dung gắn vào cộng đồng bằng cột community_id trên legacy_courses, legacy_ebooks, events. Mỗi thứ thuộc đúng một cộng đồng. community_id rỗng = nội dung chung, ai cũng đọc được.

Cộng đồng thứ 19: "Chưa phân loại" ​

Ngoài 18 cộng đồng sản phẩm còn một dòng thứ 19, dimension='holding', slug chua-phan-loai. Nó không hiện với người học (status='hidden') và không bán.

Nó tồn tại vì toàn bộ kho hiện tại, 12 khoá, 29 DeepBook, là nội dung tầng Capability (Customer Understanding, Problem Framing, Message, Offer, Sales, Strategy, Learning, AI). Không cái nào thuộc một ngành, và phần lớn không thuộc một vai trò, nên không có cách gán nào vào 18 cộng đồng kia mà đúng.

Cột communities.access quyết định chuyện đó có gây hại không:

accessNghĩa
proCộng đồng bán được. Nội dung của nó khoá, chỉ Pro đọc đầy đủ.
openAi đăng nhập cũng đọc đầy đủ. Không bán, không hiện ở màn hình chọn.

Vì sao phải có open chứ không gán vào một cộng đồng bình thường

Gán nội dung vào một cộng đồng pro là khoá nó ngay. Nếu chỗ chứa tạm cũng là pro thì câu UPDATE xếp 12 khoá và 29 DeepBook vào đó sẽ đóng sập toàn bộ kho với mọi người trong một lượt, và không có gì báo, vì hệ đang làm đúng việc nó được dặn.

community_id = NULL cũng mở, nhưng NULL không đếm được và không lọc được. Chỗ chứa tạm giữ cả hai: mở với người đọc, mà vẫn trả lời được câu hỏi vận hành duy nhất đáng hỏi lúc này, còn bao nhiêu thứ chưa được xếp chỗ, bằng một câu COUNT. Con số đó hiện ngay đầu trang quản trị.

Khi xếp một khoá sang cộng đồng thật, đừng chạy lại 20260903c: vế WHERE community_id IS NULL giữ cho nó không kéo ngược thứ đã xếp về chỗ tạm, mà chỗ tạm thì mở, nên hậu quả là mở khoá thứ vừa được bán.

Ba chỗ hỏng mà hệ vẫn trông như đang chạy

  1. Quên vế hạn dùng. Pro = tier='pro' VÀ pro_expires_at còn hạn. Bỏ vế sau thì gói năm hết hạn từ tháng trước vẫn đọc trơn tru, doanh thu gia hạn bằng không mà bảng điều khiển vẫn xanh. Luôn gọi communityTier(), đừng tự viết lại điều kiện.
  2. Gỡ community_id là mở khoá. Đặt về NULL biến nội dung thành nội dung chung, cho không cả một khoá, im lặng.
  3. Ran nghiêng theo cộng đồng chỉ khi chỉ mục có community_id. Trường này chỉ có ở các mẩu nạp sau 03.09.2026. Với chỉ mục cũ, phép ưu tiên không đổi gì cả và Ran vẫn trả lời trơn tru, kiểm một mẩu trong Vectorize, đừng suy từ việc Ran có trả lời hay không.

DeepBooks không còn khoá theo membership_tier ​

Ngày 03.09.2026, /deep-books được gỡ khỏi PAID_PREFIXES trong com.conan.school/app/lib/entitlement.ts. Từ đó, DeepBooks gác theo cộng đồng sở hữu, ở API.

Vì sao phải gỡ: hai hệ khoá độc lập chồng lên nhau thì hệ cũ thắng ở ngay cánh cửa. Người vừa mua Pro của Food, membership_tier vẫn là free, sẽ không mở nổi trang DeepBooks để đọc đúng thứ họ vừa trả tiền, và màn hình báo "cần nâng cấp thành viên", một câu vô nghĩa với người vừa trả tiền.

Bài học chung: khi thêm một tầng khoá mới, tìm và gỡ tầng cũ phủ lên cùng đường dẫn. Thêm mà không gỡ thì tầng mới trông như đang chạy, còn người dùng thì chặn ở tầng cũ.

Thư viện: mặc định là doc, không bao giờ bịa asset_url ​

Xảy ra thật 03.09.2026 khi soạn nội dung cho ba cộng đồng đầu: cả ba lượt soạn đều sinh asset_url trỏ tới tệp không tồn tại. Để status='active' thì thẻ hiện bình thường và người vừa trả tiền bấm vào nhận 404.

LoạiCách khai
Checklist, quy trình, phiếu in, kịch bảnkind='doc', asset_url=NULL, status='active', nội dung trong body_portable
Bảng tính thật (cần công thức)kind='file'/'link', asset_url=NULL, status='hidden' cho tới khi có tệp

is_preview=1 bắt buộc là một doc đang active, bản xem thử phải mở được ngay, không phụ thuộc tệp nào. Đếm status='hidden' là danh sách tệp còn phải làm.

Kệ khung 08.09.2026 cố ý đi khác một nhánh. 1080 tài liệu khung (18 cộng đồng × 10 collection × 6 tài liệu) để status='active' dù asset_url và body_portable đều NULL. Không rơi vào bẫy 404 ở trên vì tất cả đều is_preview=0: người xem thấy tên tài liệu và một ổ khoá, không có nút mở, nên không có đường nào dẫn tới 404. Nhưng luật cũ trở lại nguyên vẹn khi bật xem thử, is_preview=1 mà chưa có body_portable thật là đúng cái bẫy cũ.

SELECT COUNT(*) FROM community_library_docs WHERE asset_url IS NULL AND body_portable IS NULL là số tài liệu còn phải soạn.

Lớp kiểm tự động cho tài liệu Thư viện (08.09.2026) ​

scripts/check-library-docs.mjs, chạy trong job guards của ci-checks nên mọi PR đều qua. Đây là lớp kiểm DUY NHẤT của tài liệu thư viện: POST /ops/audit/:slug chấm khoá học và không với tới đây, còn nội dung viết tay thì đi vòng qua cả đường ống engine.

Gác kiểm bảy điều: Luật 10 (file/link không tệp thì không được active); is_preview=1 phải là doc active có nội dung thật; body_portable parse được thành mảng block; _key không trùng; style nằm trong bộ PortableTextRenderer vẽ được; tài liệu đã có nội dung phải đủ chất (≥ 4 block, ≥ 1 tiêu đề, ≥ 400 ký tự); không còn chữ giữ chỗ. Nó không bắt tài liệu phải có nội dung, kệ khung chỉ có tên là hợp lệ và cố ý.

Hai lựa chọn thiết kế đáng giữ:

  • Thực thi SQL bằng node:sqlite chứ không đọc bằng regex. Gác dựng lại bảng, áp mọi câu lệnh chạm tới ba bảng thư viện theo đúng thứ tự migration, rồi soi kết quả cuối. Phần tự làm duy nhất là tách câu lệnh bằng bộ quét có biết dấu nháy, nội dung tiếng Việt đầy ; và '. Khoá ngoại để BẬT, nên gác bắt luôn kiểu lỗi đã làm hỏng deploy 08.09.
  • Gác tự chứng minh nó bắt được lỗi. Sáu mẫu lỗi thật chạy mỗi lượt; luật nào ngừng bắt mẫu của chính nó thì gác đỏ với câu "đang xanh mà không kiểm gì".

Lần chạy đầu bắt 277 vi phạm Luật 10 có thật trong kệ khung vừa merge, sửa bằng 20260908h.

Lớp chấm chữ bằng model (08.09.2026) ​

Gác CI kiểm được hình dạng, không đọc được chữ. Nó bắt tài liệu rỗng, sơ sài, còn chữ giữ chỗ; nó không phân biệt được một tài liệu 3.000 chữ dùng được với một tài liệu 3.000 chữ toàn lời khuyên chung. src/domains/course-intelligence/library-audit.ts trả lời phần đó.

Lớp này không thay gác CI. Gác vẫn là cửa chặn, đếm được, và nó mới làm PR đỏ. Lượt chấm bằng model là ý kiến, ghi vào library_doc_audits để đọc lại, verdict='reject' nghĩa là "người soạn nên đọc lại tài liệu này trước", không chặn deploy, không tự sửa gì.

Thước sáu luật, mỗi luật nói rõ cái gì là trượt: chung-chung, khong-lam-theo-duoc, lac-nganh, so-lieu-bia (blocker); giong-sao-rong, tu-mau-thuan (warn). Mỗi lỗi phải trích được nguyên văn một câu trong bài; không trích được thì không phải lỗi.

Ba hàng rào giữ cho phép đo không tự khen mình:

  1. Model chấm phải khác model viết. auditCourse cố ý không dùng model vì model chấm bài của chính nó thì gần như luôn cho qua. Tài liệu thư viện do agent họ Claude viết, nên assertJudgeIsNotWriter() loại mọi model claude-* khỏi ghế giám khảo; mặc định gpt-oss-120b.
  2. Điểm phải biết xuống. POST /ops/library-audit/selftest đưa một bài rỗng tuếch cố ý cho đúng model và prompt đang dùng; chấm trên 0.7 hoặc ra pass thì lượt đó báo thước hỏng, không phải bài đạt. Lượt chấm hàng loạt tự chạy bài tự kiểm này TRƯỚC, và dừng nếu nó không đạt.
  3. Ghi cả hai đầu. Mọi lượt, kể cả hỏng, ghi model, prompt_version, content_hash, duration_ms, raw_reply, error_text.

Điều kiện chấm lại là "đã đủ chưa", không phải "đã có chưa": staleLibraryDocs() so content_hash của bài với hash của bản chấm gần nhất, chứ không hỏi NOT EXISTS(audit) và cũng không so updated_at (mốc đó đổi cả khi chỉ sửa tiêu đề).

Sửa RUBRIC thì phải tăng PROMPT_VERSIONS['library-doc-audit'] trong arena.ts.

Chưa có lịch tự chạy. Bốn endpoint là cửa tay: /ops/library-audit/selftest, /ops/library-audit/stale, POST /ops/library-audit, /ops/library-audit/report. Máy điều phối nội dung xếp lịch theo khoá học, và nhét tài liệu thư viện vào hàng đó là kéo cả hình dạng course vào một thứ không thuộc khoá nào. Đây là lỗ đã biết, ghi ra thay vì để nó âm thầm.

Đường dẫn ​

API /api/communities, danh sách · /:slug · POST /:slug/join · /:slug/{courses,books,library,events,posts} · POST /:slug/orders · GET /home · GET /me/orders. Quản trị /api/admin/communities, giá, gắn nội dung, duyệt đơn (cấp đúng 12 tháng, nối tiếp hạn cũ). Tokyo /communities (chọn) · /c/:slug · /c/:slug/pro. Admin Community → Members → Communities.

Thanh toán ​

Cổng chưa nối. POST /:slug/orders tạo một đơn pending thật và không cấp quyền gì. Chỗ duy nhất biến pending thành Pro là admin duyệt tay, hoặc webhook của cổng khi nối xong. Đừng thêm nhánh thứ ba.

Đừng nhầm ba chữ "community" ​

ChỗLà gì
communities / /api/communitiesTaxonomy này.
/api/community/*Bí danh cũ của domain sự kiện. Xem src/domains/events/.
/community/* ở adminCụm nav members/mentors/leads/email. Không liên quan.

Lối chọn cộng đồng (đổi 04.09.2026) ​

Lưới cộng đồng nằm thẳng trên trang chủ tokyo (com.conan.school/app/routes/_index.tsx), không còn một thẻ hub dẫn sang /communities. Bấm một thẻ là join + set_home rồi đi vào /c/:slug ngay.

Cả trang chủ lẫn /communities dùng chung app/components/community/CommunityPicker.tsx, hook useCommunities() (nạp + join) và <CommunityGrid> (lưới hai chiều ngành/nghề). Tách đôi là hai màn sẽ trôi khác nhau; đó đúng là cách trang chủ từng mất lối chọn cộng đồng.

Route /communities vẫn phải còn: màn chọn bắt buộc sau đăng nhập là /communities?first=1.

Thẻ cộng đồng: icon Heroicons (COMMUNITY_ICONS theo slug, fallback CubeIcon), tên tiếng Việt + tagline tiếng Việt. Không dòng mô tả tiếng Anh (name_en/includes_vi chỉ dùng ở admin), không nhãn "Vào xem", cả thẻ là nút bấm.

Trang một cộng đồng /c/:slug (đổi 07.09.2026) ​

Ba thay đổi về hình dạng, và một cột schema mới đi kèm:

  • Không có logo Conan cạnh nhãn cộng đồng. HubHeader vẽ logo chỉ khi trang KHÔNG có nhãn ngữ cảnh; ở /c/:slug nhãn tự mang tên nhà (Conan <tên> Community). Bảng chỉ đường ở Trang chính cũng chỉ bày mục nào chưa có khối riêng ở dưới, xét theo khối thật sự vẽ ra.
  • Tên cộng đồng lên thanh trên. HubHeader (root.tsx) nhận nhãn ngữ cảnh qua store ngoài React app/lib/page-chrome.ts, usePageChrome({emoji, title_vi, title_en}). Phải là store ngoài, không phải context: header nằm ở root.tsx, tức CHA của mọi route, nên context do route cung cấp thì header không bao giờ đọc thấy. Trang tự gỡ nhãn khi rời đi; quên gỡ thì nhãn cũ đi theo sang trang khác.
  • Bỏ dòng đếm "6 khoá · 1 Book · 3 tài liệu · 2 thành viên". Endpoint /api/communities/:slug vẫn trả counts, chỗ khác còn dùng.
  • Khoá học là thẻ, ba cột, chỉ tên + ảnh. Các tab còn lại giữ nguyên dạng danh sách. Trang dùng trọn bề ngang: root.tsx bỏ khung max-w-5xl cho mọi đường dẫn /c/.

Hai cột mới của legacy_courses ​

name_en và image_url (migration 20260907a, backfill tên tiếng Anh cho 74 khoá ở 20260907b).

Nút EN/VI ở thanh trên là LangToggle, cùng một store display-lang.ts mà trang học đã dùng, nên đổi ở thanh trên là đổi cả trong bài học. Không có name_en thì pickName() lùi về tên tiếng Việt: nút bấm mà không đổi gì và không báo gì, đó là lý do backfill đi cùng migration thêm cột, không để sau.

image_url để trống được: thẻ lùi về khung bg-accent với chữ đầu của tên. Đừng chèn một ảnh mặc định chung, mọi thẻ giống hệt nhau còn khó đọc hơn khung chữ cái.

Ô upload ảnh ở admin ​

admin.conan.school → Course Content → chi tiết khoá: khối Ảnh đại diện ngay đầu trang, cạnh ô sửa Tên tiếng Anh. Hai trường này quyết định thẻ khoá trông ra sao ở /c/:slug, nên đặt cạnh nhau chứ không giấu trong bảng cài đặt.

ĐườngViệc
POST /api/admin/learning/courses/:id/imagemultipart file → Images nén WebP → R2 → ghi image_url
POST /api/admin/learning/courses/:id/image/from-library{photo_id} → trỏ image_url vào ảnh Media Library có sẵn
DELETE /api/admin/learning/courses/:id/imagexoá cột, và chỉ xoá tệp nếu là ảnh do khoá tự tải lên

Dùng lại đúng đường ống ảnh của workshop (optimizeWorkshopImage + bucket EVENT_MEDIA + R2_PUBLIC_URL). Ba điểm đã cân nhắc, đừng "gọn hoá" lại:

  • Khoá tệp mang UUID, không ghi đè theo id khoá. R2 phục vụ ảnh với immutable, max-age=1 năm; ghi đè cùng đường dẫn thì đổi ảnh xong vẫn nhìn thấy ảnh cũ, không lỗi nào để lần theo.
  • Ảnh cũ xoá SAU khi ghi D1 thành công, và hỏng thì bỏ qua. Một tệp mồ côi trong R2 rẻ hơn một khoá trỏ tới tệp đã xoá.
  • Chỉ xoá tệp có tiền tố course-covers/ (deleteOwnCourseCover()). Một khoá có thể đang trỏ tới ảnh Media Library, và ảnh đó dùng chung với album và các trang khác. Xoá theo URL mà không xét tiền tố thì đổi ảnh cho một khoá làm ảnh biến mất ở mọi nơi khác, D1 vẫn giữ nguyên URL nên không chỗ nào báo lỗi, chỉ còn ảnh vỡ.
  • Ghi D1 hỏng thì xoá tệp vừa put, không để rác từ một lượt upload thất bại.

Nút Chọn từ thư viện mở lưới ảnh từ GET /api/admin/media-library/photos (bỏ ảnh hidden_at), nạp lúc mở chứ không nạp cùng trang. Chọn xong khoá trỏ thẳng vào public_url của ảnh thư viện, không chép ra bản riêng: ảnh thư viện đã nén sẵn bằng cùng đường ống, một bản sao chỉ tạo thêm một tệp phải dọn.

Endpoint nhận photo_id, không nhận URL do client gửi, nhận URL là mở cột này cho bất kỳ chuỗi nào, kể cả trỏ ra ngoài miền.

Trang cộng đồng /c/:slug: tab, diễn đàn, lịch chung (đổi 05.09.2026) ​

Mỗi tab một URL: /c/:slug/:tab với tab ∈ events · posts · courses · books · library. /c/:slug trần và mọi tab lạ đều rơi về events. Thứ tự tab đặt Sự kiện và Thảo luận lên đầu: đó là hai chỗ có người, ba tab còn lại là kho nội dung. Route khai ở app/main.tsx; c/:slug/pro và c/:slug/t/:postId phải đứng TRƯỚC c/:slug/:tab.

Trang dựng bằng shadcn (Tabs, Card, Badge, Button), và mọi chữ giao diện đi qua app/lib/ui-text.ts nên nút EN/VI đổi được thật, thêm nhãn mới là thêm một cặp [en, vi] ở đó, không viết chuỗi thẳng vào JSX. Ngày giờ dùng Intl với locale theo useDisplayLang().

Nút Trở thành Pro Member không nằm trong thân trang nữa mà nằm trên thanh trên, qua usePageAction(node, deps), cùng file app/lib/page-chrome.ts với nhãn ngữ cảnh, và cùng lý do: header ở root.tsx là CHA của mọi route nên phải là store ngoài React. Nhớ để ngôn ngữ đang bật vào deps, không thì nút giữ nguyên chữ cũ sau khi bấm EN/VI.

Tab DeepBooks là kệ sách ​

Sách hiện thành lưới bìa dọc (aspect-[2/3]) với tên sách bên dưới, không mô tả, không nhãn phụ; chi tiết để dành cho trang riêng của quyển (/deep-books/:slug). Không có cover_url thì lùi về khung bg-accent + chữ đầu của tên, cùng lối với thẻ khoá học; đừng đặt một ảnh bìa mặc định dùng chung, cả kệ sẽ trông y hệt nhau.

Chỉ sách is_published = 1 mới hiện ở đây, DeepBook mới chèn ở mức khung (chương chưa có section) nằm ngoài kệ cho tới khi có nội dung.

Tab Thư viện: Collection là bộ lọc (đổi 08.09.2026) ​

Mục Templates đổi tên thành Thư viện / Library, và slug đổi theo: bảng community_templates → community_library_docs, tab /c/:slug/templates → /c/:slug/library. Đổi tên bảng bằng ALTER TABLE ... RENAME TO chứ không tạo bảng mới song song, hai bảng cùng sống là kiểu hỏng tệ nhất: hai chỗ ghi, một chỗ đọc, màn hình vẫn trông như đang chạy. Endpoint /:slug/templates giữ lại như bí danh cho các bản frontend chưa cập nhật, đúng lối /ebooks → /books.

Thư viện chia thành Collection (community_collections): mỗi cộng đồng 10 ngăn, mỗi ngăn 6 tài liệu. Bốn nhóm chất liệu theo yêu cầu người soạn, tài liệu quy trình, tiêu chuẩn ngành, nghiên cứu thị phần, hồ sơ thương hiệu lớn, nằm trong 10 ngăn đó; cộng đồng Industry và Occupation có hai bộ ngăn khác nhau.

Hai điều dễ làm sai:

  • API trả collections trong cùng lượt gọi, kể cả khi đang lọc. Bộ lọc phải hiện đủ mọi ngăn, không phải chỉ ngăn đang chọn. Tách thành hai request thì lần đầu vào tab người xem thấy một hàng lọc rỗng trong khi tài liệu đã hiện.
  • collection_id NULL vẫn hiện ở "Tất cả". Tài liệu chưa xếp ngăn không được biến mất khỏi kệ, bộ lọc là bộ lọc, không phải điều kiện tồn tại.

Ngăn đang chọn sống trong URL (?collection=<slug>), không trong state: đường dẫn tới một ngăn chia sẻ được, và nút quay lại về đúng ngăn vừa xem. Lọc chạy ở trình duyệt vì cả kệ đã tải về; API vẫn nhận ?collection= cho lúc kệ lớn hơn.

Mở tài liệu ra đọc ngay trong thẻ (PortableTextRenderer), không có trang riêng. Nút "Mở ra đọc" chỉ hiện khi portableTextHasContent(body_portable), người chưa Pro không bao giờ tới nhánh đó vì API đã xoá body_portable khỏi payload chứ không chỉ gắn cờ locked (Luật 2), nên "không có nội dung" và "không được đọc" là cùng một trạng thái. Phần lớn tài liệu trên kệ mới có tên nên không có nút, cố ý: một nút mở ra khoảng trắng tệ hơn một thẻ không có nút.

Ngăn Quy trình vận hành của Education là ngăn đầu tiên có nội dung thật cả 6 tài liệu (20260908f_library_edu_ops_content.sql, viết tay). Tài liệu số 4 đổi từ kind='file' sang 'doc', nó đang là file với asset_url=NULL, đúng cái bẫy Luật 10; một bảng phân công viết được thành chữ thì không cần bảng tính.

Kệ khung sinh bằng api.conan.school/scripts/gen_library_shelf.py ra 18 filemigrations/20260908d01..18_library_shelf_*.sql, mỗi cộng đồng một file, sửa script rồi sinh lại, đừng sửa migration bằng tay. Một file cho cả 1080 tài liệu đã thử và hỏng: 372 KB, D1 bỏ dở giữa chừng và đóng băng deploy api (known-risks).

Ngăn "Báo cáo & nghiên cứu quốc tế" (01.10.2026) ​

Ngăn thứ 11 của mỗi cộng đồng (<community_id>-col-11, slug bao-cao-quoc-te, sort_order=0 nên đứng đầu kệ): 20 báo cáo công khai của McKinsey, BCG, Bain, Big 4, Kantar, NielsenIQ, Ipsos, Google, Microsoft, IBM, Salesforce, World Bank, OECD, WEF. Mỗi tài liệu là kind='link' trỏ về trang gốc của nhà xuất bản, kèm tóm tắt do Conan viết.

Không host tệp, có chủ ý. Người soạn yêu cầu tải PDF về rồi đăng lên; không làm, vì báo cáo có bản quyền và đăng lại bản PDF trên một nền tảng có gói trả phí là phân phối lại tác phẩm của họ. Chỉ host khi có giấy phép đăng lại.

Nguồn sự thật: api.conan.school/content/library/research/<slug>.json; biên dịch bằng scripts/research_library_to_sql.py <slug> [--check] (chặn: khác 20 tài liệu, dưới 5 nhà xuất bản, một nhà quá 4 sau khi quy tên về một hãng, URL không https hoặc trùng, dấu gạch dài). Migration 20261010c01..18.

Hai giới hạn của đợt đầu, ghi để lượt sau bù: McKinsey, Bain, PwC, WEF, OECD chặn công cụ đọc trang tự động (403 hoặc quá thời gian), nên phần lớn kệ thiếu ba hãng này dù người đọc mở bằng trình duyệt vẫn được; và lượt tìm kiếm web của phiên hết giữa chừng nên nhiều kệ mỏng phần Việt Nam (commerce, operations, people-hr, customer-success yếu nhất). Lượt nâng 03.10.2026 (20261012a01..04) thay 6 mục yếu nhất ở bốn kệ commerce, customer-success, operations, people-hr bằng báo cáo McKinsey, PwC (cả bản Việt Nam), KPMG Việt Nam, World Bank Việt Nam, WEF: các trang chặn công cụ đọc tự động được kiểm bằng trình duyệt thật, mỗi agent một tab riêng. Bain vào kệ qua bản viết chung đăng trên site đối tác (20261013a01..08): Bain + NielsenIQ "Southeast Asia: What's Happening with Consumers and Consumer Products" (2025) và Bain + Meta "Win With Conversations" (2024). Mục chung ghi publisher bắt đầu bằng "Bain & Company" để bộ kiểm đếm đúng hãng. Không truy cập bain.com (trình duyệt tích hợp từ chối site này). Link sống hôm nay có thể chết mai: chưa có vòng kiểm link định kỳ.

Điều hướng nằm trên thanh trên (đổi 05.09.2026) ​

Năm mục của cộng đồng (Events · Discussion · Courses · Books · Library) nằm trên HubHeader, đặt bằng usePageNav(items, deps), ô thứ ba của page-chrome.ts, cạnh nhãn ngữ cảnh và ô hành động. Thân trang mở đầu bằng tên mục đang xem (<h1>), và giữ một hàng nút dự phòng cho màn hẹp (lg:hidden) vì thanh trên giấu nav dưới lg.

Nút Trở thành Pro Member không hiện với người đã là Conan Member (resolveEntitlementGroup(profile) === 'paid'): thanh trên đã nói họ là thành viên rồi, mời mua tiếp ngay cạnh đó chỉ gây khó hiểu.

Tab Sự kiện gộp lịch chung ​

GET /api/communities/:slug/events trả sự kiện của cộng đồng CỘNG với lịch chung của trường (community_id IS NULL AND school_id = ?), mỗi dòng có scope = community | school. Chỉ sự kiện sắp tới, sắp xếp tăng dần: mốc là COALESCE(ends_at, starts_at) >= now nên một sự kiện đang diễn ra vẫn còn trên lịch. Lọc ở SQL chứ không ở giao diện, máy người dùng lệch giờ thì lọc phía trình duyệt làm mất dòng ngay trước mắt họ.

Giao diện có hai cửa sổ: 7 ngày hiện danh sách (tuần này làm gì), 30 ngày hiện lưới lịch (tháng tới dày ở đâu). Lưới bắt đầu từ thứ Hai của tuần chứa hôm nay để các cột thẳng hàng theo thứ; bắt đầu thẳng từ hôm nay thì mỗi lần mở trang lưới lại lệch một kiểu. Trước đây chỉ lọc theo community_id, mà phần lớn cộng đồng chưa có sự kiện riêng, nên tab này luôn rỗng trong khi trường vẫn có lịch, và trông y hệt một tính năng chưa làm.

Tab Thảo luận là diễn đàn ​

Feed GET /:slug/posts (mở chủ đề: POST /:slug/posts) nay có thêm trang chủ đề:

  • GET /:slug/posts/:postId, bài mở đầu + toàn bộ trả lời.
  • POST /:slug/posts/:postId/replies, trả lời; ghi community_post_replies và tăng community_posts.reply_count trong một batch, để con số ở feed không nói dối.

Giao diện: /c/:slug/t/:postId. Gác giữ nguyên một luật của cả trang, chưa Pro thì thấy tiêu đề và số trả lời, không thấy thân bài lẫn nội dung trả lời. Đừng dựng luật gác riêng cho diễn đàn ở tầng giao diện.

Trang chính của cộng đồng (05.09.2026) ​

/c/:slug đáp vào tab home (DEFAULT_TAB), không còn đáp vào events. Mục "Trang chính" đứng đầu thanh điều hướng của cộng đồng.

GET /api/communities/:slug/home trả bốn mục trong một lượt gọi, mỗi mục nhiều nhất 3 dòng: events, courses, books, posts. Bốn lượt riêng thì bốn mục hiện lệch nhau và trang giật từng khối, trang này tồn tại để nhìn một cái là biết cộng đồng đang có gì.

Hình dạng phản hồi khác các tab kia. Tab thường trả một MẢNG; home trả một OBJECT bốn mục. Vì thế frontend giữ nó ở state riêng (homeData), không nhét vào ô của items[tab], nhét vào là mở đường cho một rows.map ở nhánh khác nổ lúc chạy trong khi kiểu vẫn xanh.

"Liên quan nhất" nghĩa là gì ở đây ​

  • Khoá học có tín hiệu thật: course_enrollments của chính người đang xem (active trước considering, mới nhất trước), rồi khoá của cộng đồng, rồi khoá nền tảng, cắt còn 3, không lặp.
  • Sách và thảo luận KHÔNG có tín hiệu cá nhân nào trong dữ liệu hiện tại. Sách xếp theo sort_order; thảo luận theo ghim → số trả lời → mới nhất. Đừng viết chữ hứa hẹn "gợi ý riêng cho bạn" ở hai mục này: chúng giống nhau với mọi người, và một lời hứa cá nhân hoá không có gì đỡ phía sau là thứ người dùng phát hiện ra ngay.

Mục rỗng thì không vẽ tiêu đề rỗng, cộng đồng mới dựng có ba trong bốn mục trống, bốn tiêu đề kèm bốn dòng "chưa có gì" trông như trang hỏng. Rỗng cả bốn mới nói một câu.

Thảo luận là ngoại lệ của Luật 2 (từ 05.10.2026, quyết định của người soạn): ĐỌC mở cho mọi người (thân bài và trả lời đều trả đủ), chỉ ĐĂNG và TRẢ LỜI cần Pro. Gác ghi vẫn ở API.

Thanh trên bỏ danh sách mục; Trang chính là bảng chỉ đường (07.09.2026) ​

/c/:slug không còn đẩy danh sách mục lên thanh trên. usePageNav không còn ai gọi, hook và phần vẽ ở HubHeader vẫn giữ (trang khác vẫn dùng được), nhưng cộng đồng thì không.

Ba thay đổi đi liền nhau, gỡ một cái là hỏng hai cái kia:

  1. Thanh trên hết mục. Bảy mục xếp ngang làm tràn thanh trên ngay ở màn hình vừa, và lặp lại đúng thứ Trang chính đã bày.
  2. Nhãn cộng đồng trên thanh trên thành LIÊN KẾT về /c/:slug. PageChrome có thêm to; có to thì HubHeader vẽ <Link> thay cho <p>. Đây là đường quay lại duy nhất từ một tab bất kỳ, bỏ nó là nhốt người xem trong tab họ đang đứng.
  3. Trang chính mở đầu bằng bảng chỉ đường tới cả sáu mục còn lại (Sự kiện, Thảo luận, Khoá học, Books, Thư viện, Business Model Canvas).

Bảng chỉ đường vẽ trước và vẽ đủ, kể cả khi bốn mục nội dung dưới đều rỗng. Nó là bảng chỉ đường của cộng đồng, không phải một mục nội dung: ẩn nó theo dữ liệu là biến một cộng đồng mới dựng thành ngõ cụt, vào được Trang chính rồi không đi đâu tiếp được, và không có gì báo.

Hàng nav dự phòng lg:hidden trong thân trang cũng đã gỡ: nó chỉ tồn tại để soi lại thanh trên, và giờ bảng chỉ đường ở Trang chính chạy trên mọi bề ngang.

Sách trên Trang chính: một khuôn dùng chung (09.09.2026) ​

GET /:slug/home và GET /:slug/books dùng chung BOOK_COLUMNS, BOOK_WHERE và shapeBook() trong src/domains/communities/index.ts. Trước đó chúng là hai bản riêng và đã trôi:

  • Trang chính lọc is_published = 1, tab Books lọc is_published = 1 OR outline_level <= 2. Sách mức 1–2 chưa publish là cả cái kệ ở phần lớn cộng đồng, nên Food hiện 1/6 quyển trên Trang chính trong khi tab Books hiện đủ sáu.
  • Trang chính không lấy cover_svg, jobs_to_be_done_*, reader_pains_*, nên BookCard ở đó rơi về bìa dựng tạm và mất khối "người đọc đang vướng ở đâu".

Không lỗi, không cảnh báo, chỉ là một cái kệ trông như gần trống. Sửa gì về sách thì sửa ở khuôn chung, đừng chép sang một trong hai chỗ gọi.

Giao diện cũng dùng chung: Trang chính vẽ sách bằng đúng BookCard và đúng lưới grid-cols-1 lg:grid-cols-2 của tab Books. Một quyển sách phải trông y hệt ở hai nơi.

EventList nhận columns (1 hoặc 2): Trang chính xếp hai sự kiện một dòng, tab Sự kiện giữ một cột.

Một cộng đồng mỗi lúc, đổi một lần mỗi ngày (09.09.2026) ​

Luật do người vận hành quyết định: mỗi người ở đúng một cộng đồng tại một thời điểm, và đổi được nhiều nhất một lần mỗi ngày dân dụng Việt Nam, với ngày mới bắt đầu lúc 00:01.

Mốc thời gian ​

src/shared/vietnam-time.ts giữ VIETNAM_TZ, vietnamCivilDay() và nextVietnamCivilDayStart(). Ngày mới lúc 00:01 cài bằng cách lùi một phút rồi mới lấy ngày: 00:00:30 vẫn thuộc hôm qua, 00:01:00 đã sang ngày mới. Đây là quyết định của người vận hành, không phải nửa đêm tròn, đừng "sửa" nếu không có ai đổi quyết định đó.

Worker chạy UTC, nên không bao giờ so ngày bằng new Date() trần: lệch bảy giờ, mốc nhảy lúc 7 giờ sáng giờ Việt Nam, và máy dev ở UTC+7 cho kết quả KHÁC Worker nên thử ở máy không thấy. pricing.ts giờ dùng chung VIETNAM_TZ từ file này.

Mốc đổi nhà là cột RIÊNG ​

community_memberships.home_set_at (migration 20260918a), chỉ được ghi ở đúng một chỗ: nhánh set_home của POST /:slug/join.

Không dùng updated_at. Cột đó còn bị chạm bởi việc khác trên cùng dòng, duyệt đơn Pro ở admin ghi tier/pro_expires_at vào community_memberships. Nếu khoá đọc updated_at thì duyệt Pro cho một người sẽ lặng lẽ gia hạn khoá đổi nhà của họ thêm một ngày, và không có gì báo.

Hai ngoại lệ, cả hai đều cố ý ​

  • Lần chọn đầu tiên không bị khoá. Người chưa có nhà thì mọi trang khác chưa có ngữ cảnh để vẽ; khoá họ lại là nhốt họ ngoài cửa.
  • Chọn lại đúng cộng đồng đang ở không tính là đổi, đó không phải một lượt đổi.

Dòng chưa có home_set_at (có từ trước migration) coi như đã hết khoá: thà cho đổi một lần thừa còn hơn khoá một người vì thiếu dữ liệu lịch sử.

Gác ở API, báo trước ở giao diện ​

POST /:slug/join trả 409 COMMUNITY_SWITCH_LOCKED kèm current_slug, current_name_vi, next_change_at. Đây là lối duy nhất đặt cờ is_home, nên nó là chỗ duy nhất luật này sống.

GET /api/communities trả thêm home_switch trong cùng lượt gọi với danh sách, màn chọn phải báo trước khi người ta bấm, không phải để họ bấm rồi mới nhận 409. Tách thành lượt thứ hai thì lưới hiện trước lời báo, và lời báo tới sau cú bấm là vô dụng.

Giao diện (CommunityPicker) làm ba việc: dải báo luật hiện kể cả khi đang đổi được, hỏi lại trước khi đổi, và làm mờ các thẻ khác khi đã dùng lượt. Giao diện không tự tính ngày từ đồng hồ máy người dùng, máy lệch giờ hoặc ở múi giờ khác, mà luật chạy theo ngày Việt Nam.

11 khoá nền tảng đã được xếp chỗ (09.09.2026) ​

Chỗ chứa tạm cm-holding-unclassified giờ rỗng. Migration 20260919c xếp 11 khoá còn lại:

KhoáCộng đồngTag
AI Builderai-technologychoose
Customer Understandingmarketingcustomer
Messagemarketingoffer
Offersalesoffer
Salessalesgrowth
Strategyceo-executivestrategy
Problem Framingfoundercustomer
Storyfoundergrowth
Argument · Essay · Learningfounderpeople

Năm khoá tư duy/viết dồn vào founder theo quyết định của người soạn, chúng không có nhà tự nhiên theo ngành. education không phải chỗ của Learning: đó là cộng đồng của người kinh doanh giáo dục (trung tâm, dạy kèm), không phải "kỹ năng học".

Hệ quả cố ý: 11 khoá này giờ nằm sau tường phí ​

Chỗ chứa tạm là access='open', cả 18 cộng đồng đều access='pro', và legacy_courseskhông có cờ miễn phí cho từng khoá, gác chạy hoàn toàn theo tư cách Pro của cộng đồng sở hữu. Nên xếp chỗ = khoá lại. Người soạn đã được hỏi trước và chọn điều này.

Muốn một khoá vừa thuộc cộng đồng vừa mở cho mọi người thì phải thêm một cờ mới ở legacy_courses và cho courseCommunityLock() biết về nó, hiện chưa có.

Đặt course_tag cùng lúc với community_id ​

Truy vấn tab Khoá học xếp theo COALESCE(t.rank, 9999), nên khoá thiếu tag vẫn hiện nhưng rơi xuống cuối, không lỗi, chỉ là xếp sai chỗ lặng lẽ. Mỗi cặp (cộng đồng, tag) phải có thật trong community_course_tags; kiểm trước khi merge, đừng tin tag của cộng đồng này có ở cộng đồng kia.

Nhắm theo id, không theo slug: legacy_courses và courses trùng id nhưng lệch slug ở 190 khoá.

Xem trước, tham gia sau (27.09.2026) ​

Theo người soạn: màn chọn cộng đồng (trang chủ tokyo và /communities) giờ chỉ là lối vào xem. Thẻ bấm vào là sang trang tổng quan /c/:slug, không gọi join. Trước đó bấm thẻ là tham gia ngay (đặt is_home, tính một lượt đổi trong ngày), tức người ta phải quyết định khi chưa thấy gì về cộng đồng. Quyết định giờ nằm ở nút "Tham gia cộng đồng này" trên Trang chính của cộng đồng (components/community/JoinCommunity.tsx), kèm luật "mỗi ngày đổi một lần" ngay cạnh nút.

Màn chọn tối giản: chỉ cộng đồng theo ngành (nhóm "theo nghề nghiệp" đã ẩn), mỗi thẻ gồm ảnh hero của cộng đồng, tên, một dòng mô tả. Đã bỏ icon, dải báo luật đổi, dòng "Mở lại 00:01 ngày mai". Luật khoá vẫn ở API (POST /:slug/join trả 409), chỉ lời báo chuyển chỗ.

Trang chính: cụm Sách là cửa sổ nhìn vào kệ (27.09.2026) ​

Theo người soạn: cụm Sách đứng trên cụm Khoá học; ba quyển một dòng, mỗi thẻ chỉ có bìa + tên (bỏ "Người đọc đang vướng gì" và câu jobs); bấm quyển nào cũng sang trang tất cả sách /c/:slug/books, không vào thẳng sách, nút "Xem tất cả" của cụm này là nút đặc, là lối chính. Nhãn gợi ý đổi thành "Nên xem" (bỏ điểm 5/5) và chỉ gắn cho đúng một quyển: API /:slug/home giữ relevance cho quyển điểm cao nhất, bỏ ở hai quyển còn lại. Cùng ngày: tab Sách (BookCard) cũng bỏ khối "Người đọc đang vướng gì", thẻ chỉ còn bìa, tên, câu jobs, nút yêu thích.

Trang chính: các cụm tách bằng đường kẻ, cụm BMC mini (27.09.2026) ​

Theo người soạn: mỗi cụm (Sách, Khoá học, Business Model Canvas, Thảo luận) ngăn nhau bằng một đường kẻ; nút ở mỗi cụm nói rõ "Xem tất cả sách" / "Xem tất cả khoá học" / "Xem tất cả Business Model Canvas". Bìa sách nhỏ lại (tối đa 11rem), tên sách và tên khoá học căn giữa. Cụm mới Business Model Canvas bày một canvas mẫu thu nhỏ (BmcMini: chín ô đúng bố cục chuẩn, mỗi ô tên + một ý đầu), cả khối là liên kết sang tab BMC. Dữ liệu lấy từ chính GET /api/communities/:slug/bmc, nên gác Pro giữ nguyên.

Không chia nhóm khi mỗi nhóm chỉ có một mục (27.09.2026) ​

Theo người soạn: tab Khoá học chỉ chia nhóm theo tag khi mọi nhóm có từ hai khoá trở lên; nếu không, bày một lưới phẳng. Tab Sách chỉ bày nút lọc cho danh mục có từ hai quyển, và bỏ hẳn hàng lọc danh mục khi chưa có hai danh mục như vậy. Tab BMC bỏ danh sách so sánh chín ô kiểu cũ, vùng giải thích đã bày hai thương hiệu cạnh nhau theo từng ô.

Trang chính: mỗi cụm là một section (28.09.2026) ​

Theo người soạn: Sách, Khoá học, Business Model Canvas, Jobs to Be Done (mới), Thảo luận, mỗi cụm là một dải tràn bề ngang, nền xen kẽ (bg-muted / nền trang) để ngăn cách, nội dung căn giữa, và nút "Xem tất cả …" là cùng một kiểu nút đặc đặt giữa dưới nội dung. Cụm JTBD bày ba bộ đầu (tên + một dòng mô tả), dữ liệu từ GET /api/communities/:slug/jtbd.

Thảo luận mẫu (05.10.2026) ​

Mỗi cộng đồng đang hoạt động có 3 tài khoản mẫu, 9 chủ đề SCQA (mỗi tài khoản mở 3) và 10 trả lời mỗi chủ đề (18 cộng đồng, 54 tài khoản, 162 chủ đề, 1.620 trả lời). Nguồn là api.conan.school/content/forum/<slug>.json, biên dịch bằng node scripts/forum-seed.mjs compile ra migration 20261017a*_forum_seed_<slug>.sql; node scripts/forum-seed.mjs audit kiểm cấu trúc (3 chủ đề/người, không ai trả lời hai lượt liền, người hỏi chốt Answer ở lượt cuối, không gạch dài, không URL).

Tài khoản mẫu nằm ở legacy_users với account_type='seed_forum', is_active=0, không có email (không nhận thư, không đăng nhập được) và id seed-user-*. Muốn gỡ: xoá theo tiền tố seed-post-*, seed-reply-*, seed-user-*. Bài vẫn bị gác Pro như mọi bài khác. Đây là nội dung viết tay (không qua engine), nên không có dấu vết ở course_content_runs.

Giao diện luồng thảo luận (05.10.2026) ​

  • /c/:slug/t/:postId: bài mở đầu tách ba khối Bối cảnh / Vướng mắc / Câu hỏi (parseScqa), trả lời đánh số theo vị trí thật, lượt của người hỏi tô nền, lượt cuối của người hỏi gắn nhãn "Chốt lại", thanh lọc theo người tham gia, nhảy tới lượt cuối, ô trả lời dính đáy. Nội dung dựng bằng Markdown (ForumMarkdown, react-markdown, không chạy HTML thô, chỉ cho liên kết http(s)/nội bộ).
  • /c/:slug/u/:userId: hồ sơ công khai trong cộng đồng, từ GET /:slug/members/:userId. API chọn tường minh từng cột (tên, ảnh, nghề, giới thiệu), không email/điện thoại/mạng xã hội, và chỉ trả người đã đăng hoặc trả lời ở cộng đồng đó (người khác 404).
  • Feed GET /:slug/posts sắp theo hoạt động gần nhất (ghim trước), kèm last_reply_at, replier_count.
  • Avatar: ảnh nếu có, không thì chữ cái đầu trên nền màu cố định theo id (chỉ token màu).

Tài liệu nội bộ nền tảng Conan School.