---
url: https://docs.nemo12.com/architecture/sdd-050-nemo-tutor.md
description: >-
  Bản nháp NEMO TUTOR (SDD-050) ở tutors.nemo12.com: form ngắn hai phía gia sư
  và phụ huynh, ẩn danh, quan tâm, Nemo12 kết nối.
---

# 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\|tutor` | Form ngắn tạo listing |
| `/edit?side=family\|tutor` | Sửa listing đang có (về lại chờ duyệt) |
| `/browse` | Listing của phía kia, lọc theo mục tiêu, khu vực, online |

| Việc | Chỗ |
| --- | --- |
| Mã nguồn | `apps/tutors` (Vite + React + Tailwind v4 + `motion` + shadcn/ui, token DS-001, mặt `.n12-deep` như learn) |
| Deploy | Worker `nemo12-tutors` (static assets), `custom_domain` `tutors.nemo12.com`, khai ở `.github/app-paths.json`, qua `ci.yml` |
| E2E | `apps/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ía | Công khai (phía kia thấy khi đã duyệt) | Riêng tư |
| --- | --- | --- |
| Gia đình | vai (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ắn | **tê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ắn | họ tên, số điện thoại, email |

| Bảng | Vai trò |
| --- | --- |
| `tutor_listings` | Phầ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_contacts` | Phần riêng tư, một dòng một listing; từ 0321 có `learner_name`, `school` |
| `tutor_digest_items` | Gia sư nào đã được giới thiệu cho listing gia đình nào trong thư tuần (0321) |
| `tutor_interests` | Mộ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_matches` | Cặ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ẫu | Khi nào | Tới |
| --- | --- | --- |
| `tutor-listing-received` | tạo listing | chủ listing |
| `tutor-listing-approved` | admin duyệt lần đầu (khoá chống trùng theo listing) | chủ listing |
| `tutor-interest` | phía kia bấm Quan tâm (một lượt một thư) | chủ listing được quan tâm |
| `tutor-matched` | hai bên cùng quan tâm | cả 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

| Route | Việc |
| --- | --- |
| `GET /v1/tutors/me` | Bảng của tôi |
| `POST /v1/tutors/listings` | Tạ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`, `/resume` | Tạm dừng / mở lại |
| `DELETE /v1/tutors/listings/{id}` | Xoá kèm dữ liệu riêng |
| `POST /v1/tutors/interests` | Quan tâm |
| `POST /v1/tutors/interests/{id}/respond` | Quan 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}/status` | Admin: approved / hidden |
| `GET /v1/admin/tutors/matches` | Admin: hàng chờ kết nối |
| `POST /v1/admin/tutors/matches/{id}/connected` | Admin: đã 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ệc | Chỗ |
| --- | --- |
| 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ấu | Tà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ệu | `scripts/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ắt | Nú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ối | Tì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

| REQ | Mục |
| --- | --- |
| REQ-GRW-06 | §1-§10 |
