Skip to content

Data Dictionary · Khác (phần 8/11) ​

⚙️ Trang này sinh tự động từ source code bằng scripts/gen-reference.mjs — đừng sửa tay, sửa code rồi chạy npm run gen:reference.

Thuộc Data Dictionary. 24 bảng, learner_self_profiles tới outreach_orgs.

learner_self_profiles ​

Migration 0225 — Learner tự kể về mình, và ảnh thật của chính em ấy. SRC-739 (2026-09-15). ── Vì sao một bảng riêng, không thêm cột vào learners ──────────────────────────────────── learners giữ những gì HỆ THỐNG cần để vận hành: lớp, trường, trạng thái, ngày sinh. Bảng này giữ những gì MỘT NGƯỜI cần để hiểu một người: con thích gì, đã đi qua chuyện gì, tự thấy mình mạnh yếu ở đâu. Hai loại dữ liệu ấy có vòng đời và người đọc khác nhau, và quan trọng hơn: cái thứ hai là văn xuôi do một đứa trẻ viết về chính mình. Trộn nó vào bảng lõi nghĩa là mọi câu SELECT của mọi engine đều kéo theo mấy nghìn ký tự tâm sự mà chúng không dùng tới. ── Vì sao các ô là văn xuôi, không phải danh sách chọn ───────────────────────────────────── Đã cân nhắc bảng "sở thích" có mã (football, manga…). Không làm, vì mục đích chủ dự án nêu là "để các Teacher có thể hiểu thêm được" — người đọc là NGƯỜI, không phải engine. Một danh sách chọn sẽ ép mọi đứa trẻ vào những ô mà ta nghĩ sẵn, và bỏ mất đúng câu đáng đọc nhất ("con từng bỏ học tiếng Anh một năm vì cô giáo cũ chê con phát âm"). Khi nào có engine thật sự cần đọc máy được thì rút trích từ văn xuôi, chứ đừng bắt đứa trẻ khai hai lần.

migration: 0225_learner_self_profile.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE
interestsTEXT, -- Sở thích: con thích làm gì ngoài giờ học
backgroundTEXT, -- Quá khứ: con đã học thế nào trước khi tới đây
strengthsTEXT, -- Con tự thấy mình mạnh ở đâu
weaknessesTEXT, -- Con tự thấy mình yếu ở đâu
noteTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

learning_events ​

migration: 0299_learning_events_grammar_skill.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
skillTEXTNOT NULL CHECK (skill IN ('reading','listening','speaking','writing','grammar'))
actionTEXTNOT NULL CHECK (action IN ('practice','review','replay','vocab','submit','read'))
refTEXTNOT NULL DEFAULT ''
secondsINTEGERNOT NULL DEFAULT 0 CHECK (seconds >= 0 AND seconds <= 7200)
itemsINTEGERNOT NULL DEFAULT 0 CHECK (items >= 0)
correctINTEGERNOT NULL DEFAULT 0 CHECK (correct >= 0)
dayTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

Index: idx_learning_events_learner_skill_day(learner_id, skill, day DESC) · idx_learning_events_learner_day(learner_id, day DESC)

live_checkins ​

Check-in đầu buổi Sáu thang đo NGẮN, mỗi thang một câu hỏi, cộng hai ô chữ. Lý do từng cột nằm ở modules/liveSession/checkin.ts — ở đó chúng đứng cạnh chính câu hỏi sinh ra chúng, thay vì cách nhau một file. quiz_json giữ NGUYÊN câu trả lời chứ không chỉ giữ điểm: câu trả lời sai của learner là thứ mentor cần đọc, còn điểm thì tính lại lúc nào cũng được. ​

migration: 0258_live_sessions.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
session_idTEXTNOT NULL REFERENCES live_sessions(id)
learner_idTEXTNOT NULL
focus_textTEXT—
blocker_textTEXT—
quiz_jsonTEXT—
moodTEXT—
energyINTEGER—
excitementINTEGER—
goal_fitINTEGER—
pace_fitINTEGER—
created_atTEXTNOT NULL DEFAULT (datetime('now'))

Khóa ngoại: session_id → live_sessions

Index: idx_live_checkins_one(session_id, learner_id) UNIQUE

live_class_schedule ​

Migration 0272 - lịch các buổi live. SRC-972. ## Vì sao một bảng KHÁC live_sessions live_sessions (0258) là CĂN PHÒNG: nó ra đời lúc thầy cô bấm mở, mang mã bốn số, và chết khi buổi kết thúc. Nó không trả lời được câu learner hỏi trước đó ba ngày: "tuần này có buổi nào, lúc mấy giờ, nói về cái gì". Bảng này là LỊCH: nó tồn tại trước buổi học, và tồn tại tiếp sau khi phòng đã đóng. Gộp hai thứ vào một bảng thì mọi câu truy vấn về lịch phải lọc bỏ trạng thái phòng, và mọi câu về phòng phải lọc bỏ buổi chưa tới - hai tập dữ liệu chồng nhau trong một cái tên. Nối hai bên khi cần: live_sessions.schedule_id thêm ở đây, NULL với phòng mở ngoài lịch. ## Giờ lưu kèm MÚI GIỜ 2026-09-26T15:00:00+07:00, không phải 2026-09-26 15:00. Cả hệ chạy trên Cloudflare (UTC) và learner ngồi ở Việt Nam; một chuỗi giờ không mang múi sẽ được đọc là UTC ở tầng nào đó, và buổi học 15:00 chiều thành 22:00 tối trên màn hình của chính người sắp vào học. ## Link Zoom nằm trong bảng, nhưng KHÔNG phải ai cũng đọc được Cột zoom_url ở đây; luật "chỉ trả link từ 10 phút trước giờ bắt đầu" nằm ở workers/api/src/modules/ielts/liveClasses.ts. Một link phát sớm là một phòng Zoom có người lạ ngồi sẵn trước khi thầy cô vào, và đó là thứ không sửa được sau khi đã xảy ra.

migration: 0272_live_class_schedule.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
titleTEXTNOT NULL
summaryTEXTNOT NULL DEFAULT ''
starts_atTEXTNOT NULL
ends_atTEXTNOT NULL
zoom_urlTEXT—
statusTEXTNOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled','cancelled'))
created_atTEXTNOT NULL DEFAULT (datetime('now'))

Index: idx_live_class_schedule_starts(starts_at)

live_game_answers ​

Trả lời game UNIQUE(session_id, learner_id, q_index) là luật chơi được ghi vào SCHEMA, không vào tầng ứng dụng: mỗi câu trả lời đúng một lần. Một người bấm hai lần vì mạng lag, hay mở hai tab, thì cái chặn phải nằm ở chỗ không bị vòng qua được — nếu không thì điểm của cả lớp mất nghĩa. points lưu SẴN chứ không tính lại từ ms: công thức tính điểm sẽ còn chỉnh, và một bảng xếp hạng đổi số sau khi buổi học kết thúc là một bảng không ai tin nữa. ​

migration: 0258_live_sessions.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
session_idTEXTNOT NULL REFERENCES live_sessions(id)
learner_idTEXTNOT NULL
q_indexINTEGERNOT NULL
choiceINTEGERNOT NULL
correctINTEGERNOT NULL
msINTEGERNOT NULL
pointsINTEGERNOT NULL
created_atTEXTNOT NULL DEFAULT (datetime('now'))

Khóa ngoại: session_id → live_sessions

Index: idx_live_game_answers_one(session_id, learner_id, q_index) UNIQUE · idx_live_game_answers_board(session_id, learner_id)

live_participants ​

Ai đang trong phòng last_seen_at chứ không phải một cờ online: một cái cờ chỉ đúng khi mọi người thoát bằng cách bấm nút, mà thực tế là họ đóng tab, mất wifi, hoặc máy sập nguồn. Dấu thời gian thì tự nói sự thật — quá 90 giây không thấy đâu là không còn ngồi đó, và không cần ai dọn dẹp. ​

migration: 0258_live_sessions.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
session_idTEXTNOT NULL REFERENCES live_sessions(id)
learner_idTEXTNOT NULL
user_idTEXTNOT NULL
display_nameTEXTNOT NULL
joined_atTEXTNOT NULL DEFAULT (datetime('now'))
last_seen_atTEXTNOT NULL DEFAULT (datetime('now'))

Khóa ngoại: session_id → live_sessions

Index: idx_live_participants_one(session_id, learner_id) UNIQUE

live_reflections ​

Phản tư 4F cuối buổi Bốn cột rời chứ không một cột reflection_json: bốn chặng Facts · Feelings · Findings · Future là một KHUNG cố định (Greenaway), không phải một biểu mẫu sẽ còn thêm trường. Bốn cột thì đếm được "bao nhiêu learner viết được chặng Future" bằng SQL, còn một khối JSON thì không. ​

migration: 0258_live_sessions.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
session_idTEXTNOT NULL REFERENCES live_sessions(id)
learner_idTEXTNOT NULL
factsTEXT—
feelingsTEXT—
findingsTEXT—
futureTEXT—
created_atTEXTNOT NULL DEFAULT (datetime('now'))

Khóa ngoại: session_id → live_sessions

Index: idx_live_reflections_one(session_id, learner_id) UNIQUE

live_sessions ​

Migration 0258 — Buổi học trực tiếp qua Zoom có một chỗ đứng trong hệ. SRC-893, SDD-040. ## Vì sao buổi live cần bảng riêng, không mượn events hay slots slots (SDD-034) trả lời "khung giờ nào hàng tuần có thầy cô đứng lớp", events (SRC-711) trả lời "buổi ấy tên gì, tài liệu ở đâu". Cả hai mô tả buổi học TỪ BÊN NGOÀI, trước khi nó bắt đầu. Cái thiếu là bên TRONG: trong sáu mươi phút ấy ai đang ngồi đó, ai vừa trả lời check-in, ai đang kẹt ở đâu, ai vừa ăn điểm câu số bảy. Đó là dữ liệu sống trong lúc buổi học chạy và chết ngay khi nó kết thúc — trừ ba thứ đáng giữ lại: check-in, phản tư 4F, và điểm game. Nhét chúng vào events là trộn một bản ghi CẬP NHẬT mỗi hai giây vào một bảng vốn chỉ sửa mỗi tuần một lần. ## Mã lớp là chìa khoá vào phòng, nên nó phải NGẮN và phải HẾT HẠN Learner gõ mã bằng tay, thường trên điện thoại, trong lúc thầy cô đang đọc nó thành tiếng qua Zoom. Nên mã là BỐN CHỮ SỐ, 1000-9999 (SRC-906). Bản đầu của migration này viết mã sáu ký tự chữ-và-số, bỏ hẳn 0/O/1/I/L cho khỏi nghe nhầm. Bỏ những cặp ấy vẫn không bỏ được hai việc: mentor phải ĐÁNH VẦN từng chữ cái qua Zoom, và learner gõ chúng trên bàn phím điện thoại đang rất muốn sửa ABC234 thành một từ có nghĩa. Bốn chữ số thì đọc một lần là xong, và ô nhập mở thẳng bàn phím số. Cột vẫn là TEXT chứ không INTEGER, và đó là cố ý: mã là một CHUỖI KÝ TỰ người ta đọc và gõ, không phải một lượng để cộng trừ. Để INTEGER thì ngày nào đó chữ số đầu bằng 0 sẽ biến mất trên đường đi, và không có bậc nào trên đường ấy coi đó là lỗi. (Bản sinh mã hiện tránh hẳn chuyện đó bằng cách chỉ cấp 1000-9999, nhưng kiểu cột thì không nên phụ thuộc vào điều ấy.) Mã KHÔNG phải mật khẩu: nó chỉ mở một phòng đã yêu cầu đăng nhập. Ai vào cũng phải có phiên (user_id) và phải có hồ sơ learner, nên mã đoán trúng cũng không lấy được gì của ai. Cái mã chống là "vào nhầm phòng", không phải "kẻ xấu vào phòng". UNIQUE trên code chỉ áp cho buổi CHƯA kết thúc — xem index có mệnh đề WHERE ở dưới. Mã của buổi tuần trước dùng lại được cho tuần này, và điều đó là cố ý: sáu ký tự mà không bao giờ được tái sử dụng thì sẽ có ngày hết chỗ, và một thầy cô nhớ mã lớp của mình là một việc tốt.

migration: 0258_live_sessions.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
codeTEXTNOT NULL
titleTEXTNOT NULL
mentor_user_idTEXTNOT NULL
statusTEXTNOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby','live','ended'))
game_questionINTEGER—
question_openINTEGERNOT NULL DEFAULT 0
question_started_atTEXT—
created_atTEXTNOT NULL DEFAULT (datetime('now'))
started_atTEXT—
ended_atTEXT—
schedule_idTEXT— thêm ở 0272_live_class_schedule.sql

Index: idx_live_sessions_code_open(code) UNIQUE · idx_live_sessions_mentor(mentor_user_id, created_at DESC)

mail_series_state ​

Migration 0337 - mail series state and product updates. SRC-1289. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 07.10.2026: một chuỗi thư dài hạn cho từng sub-brand (NEMO IELTS trước), tối đa 3 lá mỗi tuần, gửi liên tục nhiều năm, mỗi lá hữu ích và có tin mới về dịch vụ user đã đăng ký. mail_series_state Mỗi (learner, sub-brand) một dòng: vào chuỗi khi nào, đã nhận bao nhiêu lá, lá gần nhất khi nào, và DỪNG khi nào vì sao. Không có dòng này thì nút "dừng chuỗi thư" không có chỗ ghi, và chuỗi chỉ dừng được bằng nút huỷ MỌI thư. product_updates Tin mới THẬT của từng sub-brand, do người viết và bấm đăng. Lá "có gì mới" chỉ đọc từ đây: không có tin thì lá ấy nhường chỗ cho một bài học, không bao giờ bịa ra một tính năng chưa có. Một lá nói sai về sản phẩm gửi tới người thật là thứ không rút lại được. ## Chạy lại không đổi gì Chỉ có CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS và INSERT OR IGNORE.

migration: 0337_mail_series_state_and_product_updates.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id)
programTEXTNOT NULL
enrolled_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
sent_countINTEGERNOT NULL DEFAULT 0
last_sent_atTEXT—
stopped_atTEXT—
stop_reasonTEXT—
—table constraintPRIMARY KEY (learner_id, program)

Khóa ngoại: learner_id → learners

Index: idx_mail_series_state_active(program, stopped_at)

markmaps ​

Migration 0287 - markmaps. SRC-1014. ## Vì sao migration này tồn tại mm.nemo12.com cho người đã đăng nhập lưu lại sơ đồ markmap của mình (tối đa 10 bản mỗi người, trần do API giữ). Không có bảng này thì sơ đồ chỉ sống trong localStorage của một trình duyệt. ## Chạy lại không đổi gì Chỉ có CREATE ... IF NOT EXISTS.

migration: 0287_markmaps.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
user_idTEXTNOT NULL REFERENCES users(id) ON DELETE CASCADE
titleTEXTNOT NULL
contentTEXTNOT NULL
created_atTEXTNOT NULL
updated_atTEXTNOT NULL
is_publicINTEGERNOT NULL DEFAULT 0 — thêm ở 0295_markmaps_public.sql
shared_atTEXT— thêm ở 0295_markmaps_public.sql

Khóa ngoại: user_id → users

Index: idx_markmaps_user(user_id, updated_at DESC) · idx_markmaps_public(is_public, shared_at DESC)

marlin_pain_need_competencies ​

Cầu nối need → competency của khung b21. KHÔNG lưu tên competency ở đây: tên đọc bằng join sang b21_competencies để một bản đúng duy nhất. why là câu giải thích vì sao competency này trả lời đúng need ấy, thứ khung gốc không có và cũng không nên có.

migration: 0214_marlin_pains.sql

CộtKiểuRàng buộc / ghi chú
pain_slugTEXTNOT NULL
need_codeTEXTNOT NULL
framework_codeTEXTNOT NULL DEFAULT 'nemo'
competency_codeTEXTNOT NULL
whyTEXT—
sort_orderINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (pain_slug, need_code, framework_code, competency_code)

Khóa ngoại: pain_slug, need_code → marlin_pain_needs.pain_slug, code

Index: idx_marlin_pain_need_comp(pain_slug, need_code, sort_order)

marlin_pain_needs ​

Một pain có thể có nhiều need (pain "không có động lực" có năm), nên need là bảng con chứ không phải một cột. code là định danh tiếng Anh (autonomy, self-efficacy...), label là chữ hiện trên chip, note là một câu tiếng Việt nói need đó nghĩa là gì.

migration: 0214_marlin_pains.sql

CộtKiểuRàng buộc / ghi chú
pain_slugTEXTNOT NULL REFERENCES marlin_pains(slug)
codeTEXTNOT NULL
labelTEXTNOT NULL
noteTEXT—
sort_orderINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (pain_slug, code)

Khóa ngoại: pain_slug → marlin_pains

Index: idx_marlin_pain_needs_pain(pain_slug, sort_order)

marlin_pain_quotes ​

Mỗi câu trích là MỘT DÒNG, không phải một đoạn văn ngăn bằng dấu xuống dòng: trang hiện chúng thành từng bong bóng thoại, và một ô văn bản gộp thì không tách lại được mà không đoán.

migration: 0214_marlin_pains.sql

CộtKiểuRàng buộc / ghi chú
pain_slugTEXTNOT NULL REFERENCES marlin_pains(slug)
sort_orderINTEGERNOT NULL
quoteTEXTNOT NULL
—table constraintPRIMARY KEY (pain_slug, sort_order)

Khóa ngoại: pain_slug → marlin_pains

marlin_pains ​

Migration 0214 — marlin_pains: những câu phụ huynh thực sự nói, và đường từ câu đó tới năng lực (SRC-704, chỉ đạo chủ dự án 2026-09-12). VÌ SAO CÓ BẢNG NÀY. Trang giới thiệu event cho Marlins không mở bằng "Student Portrait" mà mở bằng "Con tôi lười lắm" — chữ của chính phụ huynh. Từ đó mới bóc dần: Pain → Need → Competency. Nội dung ấy là NỘI DUNG, không phải chữ trong bundle giao diện: sửa một câu không được kéo theo một lần deploy app, và trang chi tiết phải load qua API như mọi trang nội dung khác. BỐN BẢNG, không phải một cột JSON: cột JSON thì không join được sang b21_competencies, mà chính cái join đó là giá trị của trang — tên competency luôn khớp với khung đang hiện ở b21.nemo12.com thay vì một bản sao chép tay bị lệch sau lần cập nhật khung đầu tiên. Ai đọc: workers/api modules/marlin-pains (public, không learner_id nào đi qua) → apps/marlins /marlin-pains. Không ai ghi lúc chạy; dữ liệu vào bằng scripts/seed-marlin-pains.sql.

migration: 0214_marlin_pains.sql

CộtKiểuRàng buộc / ghi chú
slugTEXTPRIMARY KEY
titleTEXTNOT NULL
summaryTEXT—
jtbdTEXTNOT NULL
insightTEXT—
sort_orderINTEGERNOT NULL DEFAULT 0

media ​

media — SDD-009 §2 thi hành lát cắt ảnh công khai (SDD-019 §2). Giữ nguyên tên cột của SDD-009 để phần còn lại của hệ media về sau là mở rộng, không phải viết lại. Bytes nằm ở R2 nemo12-content, key media/<yyyy>/<id>.<ext>.

migration: 0046_mentor_profiles_albums.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
kindTEXTNOT NULL DEFAULT 'image' CHECK (kind IN ('image'))
mime_typeTEXTNOT NULL
byte_sizeINTEGERNOT NULL
widthINTEGER—
heightINTEGER—
alt_defaultTEXT—
visibilityTEXTNOT NULL DEFAULT 'public' CHECK (visibility IN ('public','internal'))
moderation_statusTEXTNOT NULL DEFAULT 'approved' CHECK (moderation_status IN ('pending','approved','hidden','blocked'))
uploaded_byTEXTREFERENCES users(id)
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: uploaded_by → users

Index: idx_media_created(created_at DESC)

media_videos ​

Migration 0297 - kho METADATA của các video Remotion. SRC-1055. ## Vì sao migration này tồn tại Chủ dự án 25.09.2026: "Mỗi video cần có thông tin về meta-data của chúng, lưu trong database D1. Lưu toàn bộ cả prompts và nội dung của video trong database luôn. Rồi sau đó tạo ra một tính năng trong admin.nemo12.com, để quản lý toàn bộ thông tin, và meta-data, links của các video đó." Một dòng = một video. script_json là kịch bản đầy đủ (lời, chữ trên màn hình, phụ đề Việt), prompt là bản yêu cầu dùng để soạn nó. Nguồn soạn vẫn là tools/videos (registry.json + scripts-content/), vì Remotion render từ file; dòng D1 là bản ghi để TRA CỨU và QUẢN LÝ, được nạp bằng scripts/seed-videos.sql (sinh từ tools/videos/scripts/seed-sql.mjs) qua workflow seed-data. Workflow render-videos ghi duration_s và rendered_at sau mỗi lần render. visibility quyết ai xem được qua API: 'public' (trang công khai), 'owner' (chỉ tài khoản xem thử), 'hidden' (tắt). Admin đổi được cột này mà không phải deploy lại code. ## Chạy lại CREATE TABLE IF NOT EXISTS.

migration: 0297_media_videos_registry.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
domainTEXTNOT NULL
titleTEXTNOT NULL
messageTEXT—
langTEXTNOT NULL DEFAULT 'en' CHECK (lang IN ('en','vi'))
visibilityTEXTNOT NULL DEFAULT 'public' CHECK (visibility IN ('public','owner','hidden'))
pages_jsonTEXTNOT NULL DEFAULT '[]'
script_jsonTEXTNOT NULL
promptTEXT—
voiceTEXT—
srcTEXT—
notesTEXT—
duration_sREAL—
rendered_atTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
script_hashTEXT— thêm ở 0300_media_videos_script_hash.sql

Index: idx_media_videos_domain(domain)

membership_pricing ​

Migration 0278 — học phí THÀNH VIÊN 6 THÁNG, thay cho giá theo band. SRC-986. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 22.09.2026: "Học phí tiêu chuẩn là 20.000.000 để học 6 tháng… Giá 20 triệu cho 6 tháng, bất kể tăng bao nhiêu band. Gỡ bỏ gói band pricing." Đây là một thay đổi về SẢN PHẨM, không phải về con số. band_pricing (0227) bán mỗi 1.0 band tăng thêm, nên người muốn lên 2.0 band trả gấp đôi và trang phải hỏi "bạn muốn tăng bao nhiêu" trước khi nói được giá. Từ nay Nemo12 bán thời gian: 6 tháng học không giới hạn, giá như nhau dù learner tăng 0.5 hay 2.0 band. ## Vì sao một bảng mới chứ không sửa band_pricing tại chỗ Vì cái tên. Cột max_months_per_band và cả cái tên bảng đều khai một đơn vị KHÁC với thứ nó sẽ chứa; giữ chúng lại là để một cái bẫy nằm sẵn cho người đọc sáu tháng nữa, người sẽ tin cái tên chứ không đi đọc migration này. Tên sai còn tệ hơn tên dài. band_pricing KHÔNG bị xoá: ba dòng của nó là lịch sử giá, và một hoá đơn đã phát hành theo mức ấy vẫn phải giải thích được. Hạ cờ active là đủ — xem khối 3. ## Hai đợt ưu đãi, và vì sao chúng phải TỰ rụng Giảm 50% (còn 10.000.000) tới hết 30.09.2026; giảm 25% (còn 15.000.000) từ 01.10.2026 tới hết 31.12.2026; sau đó về mức thường. Hạn nằm trong dữ liệu chứ không chờ ai hạ cờ, vì 01.10.2026 rơi vào Chủ nhật và một đợt quên tắt là một đợt bán sai giá suốt một tháng — sai im lặng, không cổng nào kêu, chỉ có người đọc tin rồi hỏi lại. Cùng quy ước với 0227: ends_at là thời điểm HẾT hiệu lực, so bằng &lt; chứ không &lt;=. Ghi 2026-10-01T00:00:00+07:00 nghĩa là bán hết ngày 30.09.2026, và không ai phải nghĩ xem 23:59:59 có còn kịp không. Offset +07:00 bắt buộc: thiếu nó thì "hết ngày 30.09" nghĩa là hết lúc nào còn tuỳ máy nào đọc. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS, INSERT … ON CONFLICT DO NOTHING (giá thuộc về người sửa ở admin, migration chỉ đảm bảo dòng CÓ MẶT — cùng lý lẽ đã ghi ở 0227), và UPDATE … WHERE active = 1.

migration: 0278_membership_pricing_thay_cho_band_pricing.sql

CộtKiểuRàng buộc / ghi chú
codeTEXTPRIMARY KEY
kindTEXTNOT NULL CHECK (kind IN ('base', 'offer'))
label_viTEXTNOT NULL
amount_vndINTEGERNOT NULL CHECK (amount_vnd >= 0)
monthsINTEGERCHECK (months IS NULL OR months > 0)
terms_viTEXT—
starts_atTEXT—
ends_atTEXT—
seqINTEGERNOT NULL DEFAULT 1
activeINTEGERNOT NULL DEFAULT 1

Index: idx_membership_pricing_active(active, kind, seq)

mentor_profiles ​

mentor_profiles — trang giới thiệu, KHÔNG cấp quyền (quyền vẫn ở role_assignments). user_id cho phép NULL: advisor thường không dùng cổng làm việc (Q-134).

migration: 0046_mentor_profiles_albums.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
user_idTEXTUNIQUE REFERENCES users(id)
kindTEXTNOT NULL DEFAULT 'mentor' CHECK (kind IN ('mentor','advisor'))
full_nameTEXTNOT NULL
short_nameTEXT—
headlineTEXT—
photo_media_idTEXTREFERENCES media(id)
current_orgTEXT—
subjects_jsonTEXTNOT NULL DEFAULT '[]'
years_experienceINTEGER—
bioTEXT—
bio_parentTEXT—
bio_studentTEXT—
alumni_school_idTEXT—
alumni_schoolTEXT—
alumni_school_shortTEXT—
alumni_trackTEXT—
alumni_grad_yearINTEGER—
alumni_verifiedINTEGERNOT NULL DEFAULT 0 CHECK (alumni_verified IN (0,1))
alumni_verified_atTEXT—
alumni_verified_byTEXTREFERENCES users(id)
alumni_noteTEXT, -- NỘI BỘ, không bao giờ ra API công khai (SDD-019 §4)
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived'))
display_orderINTEGERNOT NULL DEFAULT 100
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
slugTEXT— thêm ở 0070_mentor_slug.sql
website_urlTEXT— thêm ở 0077_mentor_links.sql
substack_urlTEXT— thêm ở 0077_mentor_links.sql
facebook_urlTEXT— thêm ở 0077_mentor_links.sql
card_tagTEXT— thêm ở 0247_mentor_card_tag.sql
expertise_bioTEXT— thêm ở 0248_mentor_split_bio.sql
personal_bioTEXT— thêm ở 0248_mentor_split_bio.sql

Khóa ngoại: user_id → users · photo_media_id → media · alumni_verified_by → users

Index: idx_mentor_profiles_public(status, kind, display_order) · idx_mentor_profiles_school(alumni_school_id) · idx_mentor_profiles_slug(slug) UNIQUE

mentor_slots ​

weekday theo strftime('%w') của SQLite: 0 = Chủ nhật … 6 = Thứ bảy. Chọn quy ước của chính hệ CSDL đang dùng để câu truy vấn lọc theo ngày không phải cộng trừ ở tầng ứng dụng.

migration: 0223_mentor_slots.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
mentor_user_idTEXTREFERENCES users(id)
subjectTEXTNOT NULL DEFAULT 'ielts'
weekdayINTEGERNOT NULL CHECK (weekday BETWEEN 0 AND 6)
start_timeTEXTNOT NULL, -- 'HH:MM' giờ Việt Nam
end_timeTEXTNOT NULL
capacityINTEGERNOT NULL DEFAULT 6 CHECK (capacity >= 1)
meeting_urlTEXT—
noteTEXT—
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
effective_fromTEXT—
effective_toTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: mentor_user_id → users

Index: idx_mentor_slots_open(subject, status, weekday) · idx_mentor_slots_mentor(mentor_user_id, status)

nemo_ledger ​

Sổ từng khoản. Không bao giờ sửa một dòng đã ghi: sai thì ghi một dòng đối ứng mang số âm, để lịch sử còn nguyên vết.

migration: 0261_nemo_leaderboard.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
amountINTEGERNOT NULL
kindTEXTNOT NULL CHECK (kind IN ('weekly_streak','monthly_rank','manual'))
refTEXTNOT NULL DEFAULT ''
noteTEXTNOT NULL DEFAULT ''
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
paid_atTEXT—
paid_byTEXTREFERENCES users(id)

Khóa ngoại: learner_id → learners · paid_by → users

Index: idx_nemo_ledger_learner(learner_id, created_at DESC) · idx_nemo_ledger_unpaid(paid_at)

nemo_wallets ​

Số dư ví, tính bằng Nemo. 1 Nemo = 1.000 đồng (chốt với chủ dự án 2026-09-20), nên thưởng tuần 100.000đ vào sổ là 100 Nemo và đứng cùng thang với giải tháng 300/200/100.

migration: 0261_nemo_leaderboard.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE
balanceINTEGERNOT NULL DEFAULT 0
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

outreach_attempts ​

migration: 0308_school_codes_and_outreach.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
org_idTEXTNOT NULL REFERENCES outreach_orgs(id) ON DELETE CASCADE
channelTEXTNOT NULL CHECK (channel IN ('email', 'facebook_inbox', 'linkedin'))
kindTEXTNOT NULL DEFAULT 'initial' CHECK (kind IN ('initial', 'reminder'))
template_idTEXT—
languageTEXT—
to_addressTEXT—
subjectTEXT—
statusTEXTNOT NULL CHECK (status IN ('sending', 'sent', 'failed', 'skipped', 'logged'))
message_idTEXT—
errorTEXT—
sent_byTEXT—
sent_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
vn_dateTEXTNOT NULL -- ngày lịch Việt Nam, để đếm trần theo ngày

Khóa ngoại: org_id → outreach_orgs

Index: uq_outreach_email_once(org_id, kind) UNIQUE · idx_outreach_attempts_day(channel, vn_date, status) · idx_outreach_attempts_org(org_id, sent_at)

outreach_blocks ​

Danh sách chặn: email cụ thể hoặc cả tên miền. Bộ gửi đọc bảng này (và email_suppressions) ngay trước mỗi lá thư.

migration: 0308_school_codes_and_outreach.sql

CộtKiểuRàng buộc / ghi chú
keyTEXTPRIMARY KEY, -- chữ thường: 'info@school.edu' hoặc 'school.edu'
scopeTEXTNOT NULL CHECK (scope IN ('email', 'domain'))
reasonTEXT—
created_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

outreach_orgs ​

── Outreach ──────────────────────────────────────────────────────────────────────────────────

migration: 0308_school_codes_and_outreach.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
countryTEXTNOT NULL, -- ISO 3166-1 alpha-2, chữ HOA
languageTEXTNOT NULL DEFAULT 'en', -- ngôn ngữ thư: vi, en, id, th, ...
typeTEXTNOT NULL DEFAULT 'school'
—table constraintCHECK (type IN ('school', 'association', 'ministry', 'department', 'council', 'other'))
name_localTEXTNOT NULL
name_enTEXT—
cityTEXT—
websiteTEXT—
emailTEXT, -- chỉ email CHÍNH THỨC công khai, chữ thường
facebook_urlTEXT—
linkedin_urlTEXT—
source_urlsTEXT, -- mảng JSON các URL chứng minh email/trang
confidenceTEXTNOT NULL DEFAULT 'medium' CHECK (confidence IN ('high', 'medium', 'low'))
notesTEXT—
contact_greetingTEXT, -- ghi đè câu chào mặc định của khuôn thư
statusTEXTNOT NULL DEFAULT 'new'
—table constraintCHECK (status IN ('new', 'contacted', 'replied', 'interested', 'joined', 'declined', 'opted_out', 'bounced'))
approvedINTEGERNOT NULL DEFAULT 0 CHECK (approved IN (0, 1))
approved_byTEXT—
approved_atTEXT—
school_codeTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Index: uq_outreach_orgs_email(email) UNIQUE · idx_outreach_orgs_country(country, status)