---
url: https://docs.nemo12.com/architecture/sdd-049-school-outreach.md
description: >-
  Bản nháp chương trình trường học tháng 10.2026 (SDD-049): ưu đãi, mã trường và
  sổ outreach tới các trường.
---

# SDD-049 - Chương trình trường học tháng 10.2026

> Nguồn: SRC-1110 (chủ dự án 28.09.2026). Trang ưu đãi công khai là việc của SRC-1109
> (`nemo12.com/promo/schools-october-2026/<lang>`, SDD-029); tài liệu này lo phần sau trang ấy. Thay ưu đãi cũ của SRC-1057 (100 suất IELTS cho trường
> cấp 3 Việt Nam có người đại diện).

## 1. Ưu đãi, và những gì hệ thống phải giữ

Mỗi trường cấp 3 tham gia nhận **100 suất NEMO IELTS + 100 suất NEMO SAT**, miễn phí 100%, là một
chương trình khuyến mại để trường và học sinh làm quen hai chương trình. Chỉ trong **tháng 10.2026
(01.10.2026 - 31.10.2026, giờ Việt Nam)**. Trường tham gia bằng cách **trả lời thư** (hoặc viết tới
`hello@nemo12.com`); sau khi xác nhận, Nemo12 gửi trường **một mã 200 lượt**, học sinh nhập mã khi
đăng ký.

Giao diện học cho học sinh ngoài Việt Nam là **tiếng Anh**; thư mời nói rõ điều đó, không hứa bản
địa hoá sản phẩm.

## 2. Mã trường (REQ-GRW-04)

Module `workers/api/src/modules/schoolCodes/`, bảng ở migration 0308.

| Bảng | Vai trò |
| --- | --- |
| `school_codes` | Một dòng một mã: tên trường, `ielts_seats` (100), `sat_seats` (100), `valid_from`/`valid_to` (ngày lịch VN), `access_days` (90), `status` active/revoked |
| `school_code_redemptions` | Một dòng một suất đã cấp: mã, chương trình, learner, ngày hết suất. **Duy nhất (program, learner_id)** |
| `learner_sat_membership` | Gói SAT của learner, cùng hình dạng `learner_ielts_membership` (0286); SAT chưa có bảng gói nào trước đó |

**Suất đi vào mô hình gói đã có.** IELTS: `learner_ielts_membership` lên plan `member`, `ends_on`
kéo tới ngày hết suất (không bao giờ rút ngắn một kỳ dài hơn đã có). SAT: `learner_sat_membership`.
Nhờ vậy trang Membership, chip "Free: n days left" và thư nhắc kỳ dùng thử (chỉ đọc plan `trial`)
tự đổi theo mà không phải sửa chỗ nào.

**Idempotent và không tràn suất**, cả hai chốt nằm ở D1:

* Chỉ mục duy nhất `(program, learner_id)`: nhập lại cùng mã trả `already: true`, nhập mã của
  trường thứ hai cho cùng chương trình bị từ chối `already_redeemed`.
* Câu `INSERT ... SELECT ... WHERE (đếm suất đã dùng) < (trần)` đếm và ghi trong **một** câu lệnh,
  nên hai học sinh bấm cùng lúc lúc còn một suất không thể cùng lọt (có test).

**Lời từ chối hai thứ tiếng** (`REFUSALS` trong `service.ts`): `invalid` 404, `revoked` 410,
`not_started` 409, `expired` 410, `full` 409, `already_redeemed` 409. Mỗi lỗi mang `message`
(tiếng Anh, cho sân IELTS - SRC-834) và `message_vi`. Một chương trình được, một chương trình hết
suất thì vẫn 200 và từng dòng `results` nói bên nào vì sao.

| Route | Ai | Việc |
| --- | --- | --- |
| `GET /v1/public/school-codes/{code}` | công khai | Tên trường, hạn, suất còn lại mỗi chương trình (màn đăng ký, chưa có phiên) |
| `GET/POST /v1/learners/{learnerId}/school-code` | learner, bố mẹ (`canAccessLearner`) | Đọc suất đang có / nhập mã |
| `GET/POST /v1/admin/school-codes` | admin | Liệt kê kèm số suất / tạo mã (`NEMO-XXXXXX`, bảng chữ không có 0/O/1/I/L) |
| `GET /v1/admin/school-codes/{code}/redemptions` | admin | Lượt nhập |
| `POST /v1/admin/school-codes/{code}/revoke` | admin | Thu hồi: không nhập thêm được, suất đã cấp giữ nguyên |

**Học sinh nhập mã ở hai chỗ** (learn):

1. Màn đăng nhập của sân IELTS (`ielts/SignInGate.tsx`): ô "School code (optional)", hoặc link
   có `?school_code=`. Gõ xong thấy ngay tên trường và số suất còn lại. Mã giữ trong localStorage
   và `AuthProvider` nhập hộ ngay khi biết learner (`ielts/schoolCodeClaim.ts`).
2. Trang `/ielts/membership`: thẻ "School code" (tiếng Anh đơn giản, SRC-1019), liệt kê suất đang
   có và ô nhập mã.

## 3. Sổ outreach (REQ-GRW-05)

Module `workers/api/src/modules/outreach/`, tab **Outreach** ở admin.nemo12.com
(`apps/admin/src/pages/Outreach.tsx`).

| Bảng | Vai trò |
| --- | --- |
| `outreach_orgs` | Trường, hiệp hội, bộ/sở giáo dục, hội đồng: country, language, type, name_local/en, city, website, email chính thức, facebook_url, linkedin_url, source_urls (JSON), confidence, status, `approved`, school_code |
| `outreach_attempts` | Mỗi lượt liên hệ: channel `email`/`facebook_inbox`/`linkedin`, kind initial/reminder, template, ngôn ngữ, sent_at, sent_by, status, message id, lỗi, `vn_date` |
| `outreach_status_log` | Lịch sử trạng thái + ghi chú (replied, interested, joined, declined, opted_out, bounced) |
| `outreach_blocks` | Danh sách chặn: email hoặc cả tên miền |
| `outreach_templates` | Khuôn thư theo ngôn ngữ và loại, là **dữ liệu** (sửa không cần deploy), có cờ duyệt |

**Nạp CSV** `country,name_local,name_en,city,type,website,email,facebook_url,linkedin_url,language,confidence,source_urls,notes`
(`csv.ts`, bộ đọc RFC 4180 tự viết). Trùng email (hoặc trùng country + name_local khi không có
email) thì cập nhật liên hệ, **không** đụng status/approved. Dòng sai (mã quốc gia, email) báo lại
kèm số dòng. Chỉ nhận email chính thức công khai của trường, không email cá nhân giáo viên - đây là
luật cho người soạn danh sách, cột `source_urls` là chỗ chứng minh.

**Facebook**: Page không mở được hội thoại với Page khác, nên chủ dự án inbox từ trang cá nhân,
tối đa **20/ngày**. Màn admin có nút "Đã inbox FB" ghi một lượt `logged`, và bộ đếm "x/20 hôm nay".
Vượt 20 vẫn ghi (một tin đã gửi thật không được mất khỏi sổ) nhưng bộ đếm chuyển màu và API trả
`over_limit`.

Đặt trạng thái `opted_out` hoặc `bounced` tự đưa email vào danh sách chặn.

## 4. Gửi thư mời (REQ-GRW-05)

**Kênh**: dùng lại Cloudflare Email Service qua binding `EMAIL` (SRC-602), hàm mới
`sendOutreachEmail` trong `email/channel.ts`. Khác `sendEmail` ở hai chỗ: đi từ
`Nemo12 <hello@nemo12.com>` với `replyTo: hello@nemo12.com` và **không** lùi về `no-reply@` khi bị
từ chối (thư mời mà trả lời vào hộp không ai đọc thì thà không gửi); bản đối chứng về
`EMAIL_COPY_TO` luôn là **CC** (nhìn thấy được), không theo công tắc `EMAIL_COPY_MODE`: chỉ đạo chủ
dự án 28.09.2026 (SRC-1116) là mọi thư đi từ `hello@nemo12.com` phải CC `dac2205@gmail.com` để chủ
dự án theo dõi, thay cho BCC ban đầu. Đánh đổi đã được chủ dự án chấp nhận: trường nhận thư NHÌN
THẤY hộp thư cá nhân ấy trong danh sách người nhận, và "Trả lời tất cả" gửi thẳng vào đó. Địa chỉ
chỉ đến từ một giá trị cấu hình (`EMAIL_COPY_TO`), không mã cứng. Chốt xem trước `DIGEST_TEST_RECIPIENT` vẫn có hiệu lực: còn đặt thì thư về đó, tiêu đề
mang `[TEST -> địa chỉ thật]`, và lượt ấy không tiêu suất của trường.

**Khuôn thư** vi, en, id, th cho thư đầu và thư nhắc, seed ở migration 0308 với `approved = 0`.
Chỗ điền: `{{greeting}}` (câu chào của khuôn, hoặc `contact_greeting` riêng của tổ chức),
`{{school_name}}`, `{{country}}` (tên nước theo ngôn ngữ thư, `Intl.DisplayNames`),
`{{promo_url}}` (mỗi khuôn một link, mặc định trang của SRC-1109 `https://nemo12.com/promo/schools-october-2026/<lang>`). Dòng
**"reply 'unsubscribe'"** do MÃ gắn theo ngôn ngữ (`OPT_OUT` trong `sender.ts`), khuôn không gỡ
được. Sửa chữ một khuôn đã duyệt thì khuôn tự bỏ duyệt. Ngôn ngữ chưa có khuôn dùng khuôn tiếng Anh.

**Luật gửi**, tất cả trong `runBatch` (`outreach/sender.ts`); xem trước và gửi thật đi cùng một
đường mã, dừng khác chỗ:

| Luật | Thi hành |
| --- | --- |
| Chỉ gửi dòng đã duyệt | `outreach_orgs.approved = 1` (chủ dự án duyệt 10 thư đầu bằng đúng cờ này) và khuôn của ngôn ngữ ấy `approved = 1` |
| 1 thư đầu + 1 thư nhắc mỗi tổ chức | Chỉ mục duy nhất `(org_id, kind) WHERE channel='email' AND status IN ('sending','sent')`; giành chỗ trước, gửi sau |
| Thư nhắc | Chỉ khi thư đầu đã đi ít nhất 5 ngày và trạng thái vẫn `contacted` (đã trả lời thì thôi) |
| 50 thư/ngày | Đếm `outreach_attempts` theo `vn_date` (ngày lịch VN) |
| Danh sách chặn | `outreach_blocks` (email hoặc tên miền, cả tên miền con) và `email_suppressions` |
| Không gửi sau 31.10.2026 | `LAST_SEND_DAY` |
| Công tắc | `OUTREACH_SEND_ENABLED` phải đúng `"1"`; mặc định `"0"` trong `wrangler.jsonc` |
| Không bấm nhầm | `POST /v1/admin/outreach/send` đòi `confirm: true`; màn admin hỏi lại một lần |

Gửi hỏng thì dòng `failed` không giữ chỗ, lượt sau gửi lại được.

| Route (admin) | Việc |
| --- | --- |
| `GET /v1/admin/outreach/summary` | Bộ đếm email/50, Facebook/20, theo trạng thái, theo nước, công tắc |
| `GET /v1/admin/outreach/orgs?country=&status=&q=` | Danh sách lọc |
| `POST /v1/admin/outreach/import` | Nạp CSV |
| `GET/PATCH /v1/admin/outreach/orgs/{id}` | Chi tiết + lịch sử / trạng thái, duyệt, ghi chú, câu chào |
| `POST /v1/admin/outreach/orgs/{id}/attempts` | Ghi tay inbox Facebook / LinkedIn |
| `GET/PATCH /v1/admin/outreach/templates[/{id}]` | Khuôn thư, duyệt |
| `GET/POST/DELETE /v1/admin/outreach/blocks` | Danh sách chặn |
| `POST /v1/admin/outreach/preview` | Chạy khô: ai nhận thư nào, thư đã điền; không gửi, không ghi |
| `POST /v1/admin/outreach/send` | Gửi lô đã duyệt |

## 5. hello@nemo12.com: đã kiểm gì, chủ dự án phải làm gì

Kiểm DNS ngày 28.09.2026 (chỉ đọc, `dig`):

| Bản ghi | Hiện trạng |
| --- | --- |
| `MX nemo12.com` | **Không có.** Thư gửi tới `hello@nemo12.com` hiện **không có chỗ nhận**, trường trả lời sẽ bị trả về |
| `TXT nemo12.com` (SPF) | Không có |
| `TXT _dmarc.nemo12.com` | `v=DMARC1; p=reject;` |
| `MX/TXT cf-bounce.nemo12.com` | MX `route1..3.mx.cloudflare.net`, SPF `include:_spf.mx.cloudflare.net` - đây là tên miền gửi của Email Service (SRC-602), không nhận thư |

Repo không có Worker `email()` nào và không có tài liệu nào về Email Routing cho tên miền gốc. Vì
vậy công tắc gửi để `"0"`. Trước khi bật, chủ dự án làm trên Cloudflare dashboard:

1. **Email Routing cho `nemo12.com`** (Email > Email Routing > Enable). Cloudflare tự thêm ba bản
   ghi MX `route1/2/3.mx.cloudflare.net` và một TXT SPF `v=spf1 include:_spf.mx.cloudflare.net ~all`
   ở tên miền gốc. Kiểm lại không có MX cũ nào khác.
2. **Quy tắc**: `hello@nemo12.com` -> `dac2205@gmail.com` (địa chỉ đích đã *Verified* từ SRC-602).
   Không bật catch-all.
3. **Gửi thử một lá tay** từ một hộp thư ngoài tới `hello@nemo12.com`, xác nhận nó tới Gmail.
4. **Gửi đi từ `hello@`**: Email Service xác minh theo TÊN MIỀN, và `nemo12.com` đã onboard, nên
   `hello@` về nguyên tắc gửi được như `ielts@`/`sat@` (SRC-1091). Thư `no-reply@` đã tới hộp thư
   phụ huynh thật dưới DMARC `p=reject` đang có, nên chiều gửi của tên miền đã qua DMARC; phiên này
   KHÔNG kiểm được bản ghi DKIM (selector nằm trong dashboard), nên đừng coi đây là đã xác minh.
   Hai việc phải kiểm trước lượt gửi thật: (a) nạp một dòng thử mang email của chính chủ dự án,
   duyệt, gửi lô chỉ có dòng ấy, rồi mở "Show original" trong Gmail, thấy `SPF: PASS`, `DKIM: PASS`, `DMARC: PASS`; (b) Email
   Service còn giới hạn "chỉ gửi tới địa chỉ đã xác minh" của bản beta không (SDD-005 §5.1). Nếu
   còn, thư tới trường bị `E_RECIPIENT_NOT_ALLOWED`, lượt ấy ghi `failed`, không ai nhận.
5. Muốn trả lời trường TỪ `hello@` (chứ không từ Gmail cá nhân): thêm `hello@nemo12.com` làm
   "Send mail as" trong Gmail qua một SMTP gửi được (Email Routing chỉ chuyển tiếp, không gửi).
   Không làm thì trả lời sẽ đi từ `dac2205@gmail.com`.
6. Làm xong 1-3: đổi `"OUTREACH_SEND_ENABLED": "1"` trong `workers/api/wrangler.jsonc`, commit, CI deploy.

Trả lời "unsubscribe" hiện được xử lý TAY: người đọc hộp thư đặt trạng thái `opted_out` ở tab
Outreach, email vào danh sách chặn. Tự động hoá (Worker `email()` đọc chữ "unsubscribe") là việc
sau, cần Email Routing trước.

**Cập nhật 28.09.2026:** bước 1-2 đã xong trên dashboard. Kiểm lại bằng `dig @1.1.1.1`: MX
`route1/2/3.mx.cloudflare.net`, TXT `v=spf1 include:_spf.mx.cloudflare.net ~all`, DKIM
`cf2024-1._domainkey`. Quy tắc `hello@nemo12.com` -> `dac2205@gmail.com` Active, catch-all tắt.
Công tắc chuyển `"1"`. Bước 3-4 (thư thử tới hello@, lượt gửi thử một dòng về chính chủ dự án)
vẫn phải làm trước lô thật.

## 6. Câu hỏi còn mở

* Q-193: một suất miễn phí dùng được bao lâu? Tạm 90 ngày từ ngày nhập mã (`access_days`, sửa được
  từng mã).
* Q-194: khu SAT hiện không chặn ai theo gói, nên `learner_sat_membership` mới là sổ ghi, chưa là cổng.
* Q-195 (✅): link trang ưu đãi là trang của SRC-1109, sửa được ở admin.

## Trace

| REQ | Section |
| --- | --- |
| REQ-GRW-04 | §2 |
| REQ-GRW-05 | §3, §4, §5 |
