Skip to content

SDD-050 - NEMO TUTOR ​

Nguồn: SRC-1141 (chủ dự án 29.09.2026): "Làm form ngắn, lưu vào hệ thống. Cả hai phía đều cần lưu thông tin (cả phía gia sư, lẫn phía gia đình học sinh). Cả phía để có thể 'xem listing'. Cả hai đều không có thông tin cá nhân từ người kia. Để quan tâm tới người kia thì cần click vào vài thông tin trên hệ thống NEMO TUTOR." Trang giới thiệu công khai là việc của SRC-1139 (nemo12.com/students/tutors, SDD-029 §Nemo12 Tutors); tài liệu này lo phần sau nút "Đăng ký sớm".

1. Chỗ đứng: tutors.nemo12.com (app riêng apps/tutors) ​

SRC-1175 (chủ dự án 01.10.2026): hệ thống tách khỏi learn thành web-app riêng, thư mục riêng, sub-domain riêng, tech stack y hệt learn; Claude chọn tên. Hai quyết định:

  • Sub-domain tutors.nemo12.com (số nhiều): khớp tên dịch vụ, khớp trang giới thiệu nemo12.com/students/tutors và đường cũ /tutors, nên link người dùng đã có chỉ đổi phần host.
  • Tên dịch vụ: Nemo12 Tutors, đúng tên trang giới thiệu đã dùng từ SRC-1139. Tên nội bộ cũ "NEMO TUTOR" (chữ hoa, số ít) thôi hiện trên giao diện và trong thư, để một dịch vụ chỉ có một tên.

Ứng dụng dùng lại đăng nhập Google và phiên sẵn có: cookie phiên đặt cho cả *.nemo12.com, nên ai đã đăng nhập ở learn thì vào đây là có sẵn phiên, và CORS của API vốn mở cho mọi *.nemo12.com. Ba lý do cần danh tính: bấm Quan tâm, sửa listing của chính mình, và chặn spam. Trang trên nemo12.com vẫn là trang giới thiệu; mọi nút "Đăng ký sớm" trỏ tutors.nemo12.com/new?side=family, nút "Đăng ký làm gia sư" trỏ ?side=tutor (TUTORS_SIGNUP_* trong apps/web/src/site/tutorsProgram.ts).

Địa chỉViệc
/Bảng của tôi: listing của tôi (kèm liên hệ riêng của chính tôi), "Có người quan tâm", "Đã khớp", "Bạn đã quan tâm"
/new?side=family|tutorForm ngắn tạo listing
/edit?side=family|tutorSửa listing đang có (về lại chờ duyệt)
/browseListing của phía kia, lọc theo mục tiêu, khu vực, online
ViệcChỗ
Mã nguồnapps/tutors (Vite + React + Tailwind v4 + motion + shadcn/ui, token DS-001, mặt .n12-deep như learn)
DeployWorker nemo12-tutors (static assets), custom_domain tutors.nemo12.com, khai ở .github/app-paths.json, qua ci.yml
E2Eapps/tutors/e2e (tutors.spec.ts + mobile audit 375/320px), chạy trong job e2e của CI
Link cũlearn.nemo12.com/tutors/** trả 301 về tutors.nemo12.com/** (apps/learn/public/_redirects), giữ ?side=; lưới phía trình duyệt apps/learn/src/tutorsRedirect.ts
  • Đăng nhập: app không có hồ sơ learner; danh tính là tài khoản (users). Chưa đăng nhập thì vẽ màn đăng nhập tiếng Việt tại chỗ, URL và ?side= nằm nguyên trên thanh địa chỉ, nên sau Google load() nạp lại /v1/me và trang đi tiếp đúng form. Nút Google chỉ chạy trên origin đã khai trong OAuth client dùng chung, nên https://tutors.nemo12.com phải có trong Authorized JavaScript origins (việc làm tay trên Google Cloud Console).

2. Form ngắn và dữ liệu ​

Chỉ gia sư tiếng Anh (SRC-1184, chủ dự án 02.10.2026). Mục tiêu duy nhất: Grammar, Speaking, IELTS, A1, A2, B1, B2, Essay, SAT (phần Reading and Writing), AP (AP English Language và AP English Literature). Gia sư chỉ nhận những môn này, gia đình chỉ đăng nhu cầu học những mục này; bổ sung sau này cũng chỉ là thứ thuộc tiếng Anh. Mã cố định ở GOALS (workers/api/src/modules/tutors/routes.ts), API từ chối mọi mã khác. Listing tạo trước ngày này mang mã cũ (thi chuyên, vào đại học...) vẫn đọc được; sửa thì phải chọn lại mục tiêu tiếng Anh.

Gia đình khai khá đầy đủ về learner (SRC-1184): gia sư cần đủ để tự biết mình có hợp không, và thư tuần (§12) cần đủ để chọn đúng người. Mọi ô dưới đây của phía gia đình là bắt buộc. Mọi thứ form thu đều được lưu (migration 0315, thêm 0321).

PhíaCông khai (phía kia thấy khi đã duyệt)Riêng tư
Gia đìnhvai (học sinh / phụ huynh), năm sinh và lớp của learner, mục tiêu tiếng Anh, trình độ hiện tại, muốn đạt (ví dụ IELTS 7.0), tháng thi hoặc hạn, phần cần giúp, số buổi mỗi tuần, ngân sách mỗi buổi (5 khoảng), khu vực (quận Hà Nội hoặc Online), học online được, lịch mong muốn, ghi chú ngắntên learner, trường đang học, họ tên liên hệ, số điện thoại, email (điền sẵn từ tài khoản)
Gia sưmục tiêu tiếng Anh dạy được, phần dạy, bằng chứng tiếng Anh (mỗi dòng một ý: chứng chỉ, điểm thi, giải thưởng), kinh nghiệm, khu vực / online, lịch rảnh, giới thiệu ngắnhọ tên, số điện thoại, email
BảngVai trò
tutor_listingsPhần công khai + status + public_no. Duy nhất (user_id, side): mỗi tài khoản tối đa một listing mỗi phía
tutor_listing_contactsPhần riêng tư, một dòng một listing; từ 0321 có learner_name, school
tutor_digest_itemsGia sư nào đã được giới thiệu cho listing gia đình nào trong thư tuần (0321)
tutor_interestsMột lượt Quan tâm (from_listing_id, to_listing_id), duy nhất theo cặp; lý do, lời nhắn, trạng thái
tutor_matchesCặp đã khớp (family_listing_id, tutor_listing_id), matched / connected

3. Mô hình riêng tư ​

  • Hai bảng, không phải hai cột. Câu SELECT của trang duyệt chỉ đọc tutor_listings; không có đường nào kéo nhầm số điện thoại ra, kể cả SELECT *.
  • Một hàm chiếu cho phía kia: mọi thứ trả cho người không phải chủ đi qua publicCard() (workers/api/src/modules/tutors/routes.ts), hàm chỉ biết cột công khai, không trả user_id. Liên hệ chỉ được đọc ở ownerView() (chính chủ) và các route /v1/admin/tutors/*.
  • Nhãn ẩn danh: "Gia sư #T-2481", "Gia đình #F-1093". Số ngẫu nhiên 1000-9999, duy nhất theo phía, không tăng dần để nhãn không nói ra có bao nhiêu listing. Không tên, không ảnh.
  • Chữ tự do bị lọc (containsContact()): số điện thoại (0xxx / +84, hoặc dãy từ 10 chữ số), email, URL / tên miền. Áp cho lời nhắn Quan tâm (tối đa 300 ký tự) VÀ mọi ô chữ công khai của listing, vì một số điện thoại viết vào ô ghi chú là rò y hệt. Bị chặn thì trả 400 kèm câu giải thích tiếng Việt.
  • Thư không mang liên hệ phía kia: bốn thư ở §6 chỉ gọi phía kia bằng nhãn ẩn danh.
  • Khớp không tự chia sẻ gì: khi hai bên cùng quan tâm, hệ thống không gửi liên hệ cho ai; admin kết nối bằng tay (§5).
  • Test gác: workers/api/src/modules/tutors/routes.test.ts tìm CHUỖI liên hệ thật (tên, số, email) trong toàn bộ body của mọi response phía kia nhận (danh sách, bảng của tôi sau khi nhận quan tâm, sau khi khớp) và trong thư; không tin vào tên trường.

4. Duyệt và quyền chủ listing ​

  • Mọi listing mới ở pending; chỉ approved mới hiện cho phía kia. Có trẻ vị thành niên nên bước duyệt là bắt buộc, không phải tuỳ chọn.
  • hidden: admin ẩn. Chủ không mở lại được (409), sửa vẫn được nhưng giữ hidden.
  • Chủ sửa thì listing về pending (approved_at xoá), phải duyệt lại. Tạm dừng → paused; Mở lại → approved nếu từng được duyệt, không thì pending.
  • Mỗi phía chỉ thấy phía kia: GET /v1/tutors/listings đọc phía của người xem từ listing của chính họ (?as= khi có cả hai), trả listing approved của phía ngược lại, bỏ listing của chính tài khoản. Chưa có listing thì danh sách rỗng.

5. Luồng Quan tâm và khớp ​

  1. Người xem bấm Quan tâm trên một thẻ, chọn một hoặc vài lý do (goal_fit "Phù hợp mục tiêu", schedule_fit "Lịch học hợp", area_near "Khu vực gần", want_to_talk "Muốn trao đổi thêm"), lời nhắn tuỳ chọn. Listing của người bấm phải đã approved (409 nếu chưa), đích phải approved và thuộc phía kia (404 nếu không).
  2. Tạo một dòng tutor_interests. Idempotent: bấm lại cùng cặp trả 200 với dòng cũ, không thư thứ hai, không đếm trần.
  3. Trần 10 lượt mới mỗi 24 giờ mỗi tài khoản, đếm thẳng trong D1 (from_user_id, created_at); quá trần trả 429. Dòng "Quan tâm lại" (không lý do) không đếm.
  4. Phía kia thấy "Có người quan tâm" trên bảng: nhãn ẩn danh, lý do, lời nhắn, listing công khai của người bấm; nhận thư tutor-interest. Trả lời Quan tâm lại hoặc Không phù hợp. Người gửi không bao giờ thấy "Không phù hợp" (chỉ "Đã gửi" hoặc "Đã khớp").
  5. Khớp khi (a) người nhận bấm Quan tâm lại, hoặc (b) người nhận tự bấm Quan tâm người đã quan tâm mình. Cả hai lượt thành accepted, tạo một dòng tutor_matches (duy nhất theo cặp), hai bên nhận thư tutor-matched "Nemo12 sẽ liên hệ để kết nối". Không liên hệ nào được tự chia sẻ.
  6. Cặp vào hàng chờ admin (tab NEMO TUTOR ở apps/admin, ngăn "Cặp đã khớp") kèm liên hệ riêng của cả hai bên. Admin kết nối bằng tay rồi bấm Đã kết nối (connected, ghi connected_by).

6. Thư (giao dịch, miễn trần ngày) ​

MẫuKhi nàoTới
tutor-listing-receivedtạo listingchủ listing
tutor-listing-approvedadmin duyệt lần đầu (khoá chống trùng theo listing)chủ listing
tutor-interestphía kia bấm Quan tâm (một lượt một thư)chủ listing được quan tâm
tutor-matchedhai bên cùng quan tâmcả hai

Thư đi tới email trong phần liên hệ riêng người đó tự khai; tên trên tiêu đề là tên của chính người nhận. Mẫu: workers/api/src/modules/email/templatesTutors.ts, khai trong samples.ts.

7. Admin ​

Tab NEMO TUTOR (apps/admin/src/pages/TutorListings.tsx), cùng khuôn tab Đơn SPEAK / NEMO WALK: ngăn Chờ duyệt (Duyệt / Ẩn), Mọi listing, Cặp đã khớp (liên hệ hai bên + Đã kết nối). API /v1/admin/tutors/*: không phiên → 401 (requireSession), có phiên mà không phải admin → 401 AUTHORIZATION_ERROR theo quy ước repo.

8. Lưu giữ và xoá ​

  • Chủ xoá listing → xoá trong một batch: cặp khớp của listing, mọi lượt quan tâm HAI CHIỀU, dòng liên hệ riêng, rồi listing. Mã xoá tường minh, không dựa vào ON DELETE CASCADE (D1 chỉ thi hành khi PRAGMA foreign_keys bật).
  • Listing bị ẩn hoặc tạm dừng vẫn giữ dữ liệu cho tới khi chủ xoá.
  • Chưa có hạn xoá tự động cho listing bỏ quên (việc chờ, xem .claude/memory/viec-dang-cho.md).

9. API ​

RouteViệc
GET /v1/tutors/meBảng của tôi
POST /v1/tutors/listingsTạo listing (409 nếu đã có phía này)
GET /v1/tutors/listings?as=&goal=&area=&online=Duyệt phía kia
PUT /v1/tutors/listings/{id}Chủ sửa
POST /v1/tutors/listings/{id}/pause, /resumeTạm dừng / mở lại
DELETE /v1/tutors/listings/{id}Xoá kèm dữ liệu riêng
POST /v1/tutors/interestsQuan tâm
POST /v1/tutors/interests/{id}/respondQuan tâm lại / Không phù hợp
GET /v1/admin/tutors/listings?status=Admin: mọi listing kèm liên hệ
POST /v1/admin/tutors/listings/{id}/statusAdmin: approved / hidden
GET /v1/admin/tutors/matchesAdmin: hàng chờ kết nối
POST /v1/admin/tutors/matches/{id}/connectedAdmin: đã kết nối

Tạo / sửa listing chịu thêm hạn mức tutorWrite (30 lượt / 10 phút) chống bấm dồn.

10. Kiểm chứng ​

  • API: workers/api/src/modules/tutors/routes.test.ts (riêng tư ở danh sách, bảng, thư; mỗi phía chỉ thấy phía kia; pending ẩn; idempotent; khớp vào hàng chờ; bộ lọc lời nhắn; trần ngày; quyền admin; xoá kéo theo dữ liệu riêng).
  • tutors: apps/tutors/e2e/tutors.spec.ts (SRC-1175; trước đó ở learn) (tạo listing mỗi phía, duyệt, Quan tâm, bảng, màn đăng nhập giữ URL); bốn URL /tutors/** trong e2e/mobileAudit.spec.ts.
  • web: apps/web/src/site/studentServices.test.ts (nút trỏ sang learn, FAQ nói đúng mô hình).

11. Dữ liệu giả để test và cờ chung demo_data (SRC-1183) ​

Chủ dự án 02.10.2026: cần 100 gia sư giả và 100 gia đình giả để test, và MỘT cờ chung tắt đi là dữ liệu giả không hiện ở đâu cả.

ViệcChỗ
CờKV CONFIG, khoá flag:demo_data ("1" bật, "0" tắt, chưa đặt = bật). Đọc/ghi qua workers/api/src/shared/flags.ts
Đánh dấuTài khoản giả id demo-tutor-NNN / demo-family-NNN, email @demo.nemo12.invalid; API nhận ra dữ liệu giả qua tiền tố id demo- (id thật là UUID)
Dữ liệuscripts/gen-tutors-demo.mjs sinh scripts/seed-tutors-demo.sql (tất định, INSERT OR IGNORE), nạp qua seed-data.yml
Bật/tắtNút "Ẩn dữ liệu giả" / "Hiện dữ liệu giả" trên trang Tutors của admin, gọi PUT /v1/admin/flags/demo_data (chỉ admin)
  • Cờ ở KV, không ở vars của wrangler, không ở bảng D1: tắt bằng vars là một lượt deploy API. Bảng D1 cần migration, mà dãy migration phải liền mạch (AS-04.1.1); ngày 02.10.2026 ba số đứng trước còn nằm trên nhánh chưa gộp của phiên khác, nên migration ấy sẽ đứng chờ. KV CONFIG đã là chỗ của công tắc runtime (ai_cron_paused). Cái giá: KV lan ra mọi vùng trong khoảng một phút.
  • Tắt là biến mất ở mọi chỗ đọc: trang xem listing của phía kia, bảng của tôi (quan tâm nhận/gửi, cặp đã khớp), bấm Quan tâm vào listing giả (404 như không tồn tại), trang admin, và số đếm người dùng trên trang tổng quan admin. Dữ liệu vẫn nằm trong D1; bật lại là hiện lại nguyên vẹn.
  • KV hỏng thì coi như tắt: thà thiếu dữ liệu giả lúc test còn hơn để nó lọt ra khi đã tắt.
  • Nhãn số năm chữ số: listing thật lấy số ngẫu nhiên 1000..9999, dữ liệu giả lấy 10000..10099, nên không bao giờ trùng nhãn và người test nhìn là biết.
  • Không bao giờ gửi thư tới dữ liệu giả: isSuppressed chặn mọi địa chỉ đuôi .invalid (RFC 2606), nên chặn cho MỌI đường gửi, kể cả chiến dịch quét theo ngày tạo tài khoản như setup-nudge, vốn sẽ nhặt 200 tài khoản giả vừa tạo nếu không có luật này.
  • Test: workers/api/src/modules/tutors/routes.test.ts nạp chính file seed rồi kiểm cả hai trạng thái cờ.

12. Chợ hai phía, quyền xem và thư tuần (SRC-1184) ​

Chủ dự án 02.10.2026 mô tả sản phẩm như Grab: mỗi người có HAI việc độc lập, TÌM phía kia và ĐĂNG thông tin của mình. Trang chính có hai khối, mỗi khối một nút tìm và một nút đăng:

KhốiTìmĐăng
Gia đình, học sinh"Tìm gia sư" → /browse?view=tutor"Đăng nhu cầu học" → /new?side=family
Gia sư"Tìm học sinh" → /browse?view=family"Đăng hồ sơ gia sư" → /new?side=tutor

Quyền xem (phương án B, chủ dự án chọn):

  • Ai đăng nhập cũng xem được danh sách gia sư đã duyệt, không cần đăng gì trước.
  • Danh sách gia đình có hồ sơ trẻ vị thành niên, nên chỉ mở cho người có listing gia sư đã duyệt. Còn lại API trả locked: true và danh sách rỗng; trang nói rõ vì sao và dẫn tới form đăng hồ sơ gia sư.
  • Bấm Quan tâm vẫn cần listing của chính mình ở phía kia, đã duyệt: phía nhận cần biết người quan tâm là ai để quan tâm lại, và đó là lớp chặn spam. Chưa có listing thì nút Quan tâm dẫn thẳng tới form đăng.

"Chúng tôi không còn nhu cầu này nữa": nút trên listing gia đình, đặt closed_at. Listing rời danh sách gia sư xem, không nhận Quan tâm mới (404 như không tồn tại), và thư tuần dừng. "Mở lại nhu cầu" xoá closed_at. Dùng cột mốc thời gian chứ không thêm giá trị vào status, vì status có CHECK và đổi CHECK trong SQLite là dựng lại cả bảng.

Thư tuần (workers/api/src/modules/tutors/digest.ts, mẫu tutor-weekly-digest):

  • Chạy trong cron 12:00 UTC (19:00 giờ Việt Nam) hằng ngày, khoá riêng tutor-digest.
  • Mỗi listing gia đình đã duyệt, đang mở, không tạm dừng nhận tối đa một thư mỗi 7 ngày, tính theo từng listing; chỉ gửi khi có gia sư mới khớp, tối đa 5 người một thư.
  • "Khớp" = gia sư đã duyệt, chung ít nhất một mục tiêu, và gặp được nhau: cùng quận, hoặc cả hai cùng nhận online. Gia sư đã nằm trong tutor_digest_items của listing ấy thì không gửi lại.
  • Thư chỉ có phần công khai của gia sư (nhãn ẩn danh, mục tiêu, bằng chứng, khu vực, lịch), giống trên web, kèm nút "Xem và bấm Quan tâm". Không tên, không liên hệ: Nemo12 vẫn là bên kết nối.
  • Theo trần 3 thư/người/ngày (không miễn như bốn thư giao dịch §6): đây là thư Nemo12 chủ động gửi. Bị hoãn vì trần thì không ghi tutor_digest_items, nên lượt hôm sau gửi lại đúng những gia sư ấy.
  • Theo cờ demo_data (§11): cờ tắt thì gia sư giả không vào thư; gia đình giả không bao giờ nhận thư.

Trace ​

REQMục
REQ-GRW-06§1-§10