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ốialready_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):
- 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àAuthProvidernhập hộ ngay khi biết learner (ielts/schoolCodeClaim.ts). - 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:
- Email Routing cho
nemo12.com(Email > Email Routing > Enable). Cloudflare tự thêm ba bản ghi MXroute1/2/3.mx.cloudflare.netvà một TXT SPFv=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. - Quy tắc:
hello@nemo12.com->dac2205@gmail.com(địa chỉ đích đã Verified từ SRC-602). Không bật catch-all. - 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. - Gửi đi từ
hello@: Email Service xác minh theo TÊN MIỀN, vànemo12.comđã onboard, nênhello@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 DMARCp=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ấySPF: 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 ghifailed, không ai nhận. - Muốn trả lời trường TỪ
hello@(chứ không từ Gmail cá nhân): thêmhello@nemo12.comlà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. - Làm xong 1-3: đổi
"OUTREACH_SEND_ENABLED": "1"trongworkers/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_membershipmớ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 |