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ạynpm 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
learner_id | TEXT | PRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE |
interests | TEXT | , -- Sở thích: con thích làm gì ngoài giờ học |
background | TEXT | , -- Quá khứ: con đã học thế nào trước khi tới đây |
strengths | TEXT | , -- Con tự thấy mình mạnh ở đâu |
weaknesses | TEXT | , -- Con tự thấy mình yếu ở đâu |
note | TEXT | — |
updated_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
learner_id | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
skill | TEXT | NOT NULL CHECK (skill IN ('reading','listening','speaking','writing','grammar')) |
action | TEXT | NOT NULL CHECK (action IN ('practice','review','replay','vocab','submit','read')) |
ref | TEXT | NOT NULL DEFAULT '' |
seconds | INTEGER | NOT NULL DEFAULT 0 CHECK (seconds >= 0 AND seconds <= 7200) |
items | INTEGER | NOT NULL DEFAULT 0 CHECK (items >= 0) |
correct | INTEGER | NOT NULL DEFAULT 0 CHECK (correct >= 0) |
day | TEXT | NOT NULL |
created_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
session_id | TEXT | NOT NULL REFERENCES live_sessions(id) |
learner_id | TEXT | NOT NULL |
focus_text | TEXT | — |
blocker_text | TEXT | — |
quiz_json | TEXT | — |
mood | TEXT | — |
energy | INTEGER | — |
excitement | INTEGER | — |
goal_fit | INTEGER | — |
pace_fit | INTEGER | — |
created_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
title | TEXT | NOT NULL |
summary | TEXT | NOT NULL DEFAULT '' |
starts_at | TEXT | NOT NULL |
ends_at | TEXT | NOT NULL |
zoom_url | TEXT | — |
status | TEXT | NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled','cancelled')) |
created_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
session_id | TEXT | NOT NULL REFERENCES live_sessions(id) |
learner_id | TEXT | NOT NULL |
q_index | INTEGER | NOT NULL |
choice | INTEGER | NOT NULL |
correct | INTEGER | NOT NULL |
ms | INTEGER | NOT NULL |
points | INTEGER | NOT NULL |
created_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
session_id | TEXT | NOT NULL REFERENCES live_sessions(id) |
learner_id | TEXT | NOT NULL |
user_id | TEXT | NOT NULL |
display_name | TEXT | NOT NULL |
joined_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
last_seen_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
session_id | TEXT | NOT NULL REFERENCES live_sessions(id) |
learner_id | TEXT | NOT NULL |
facts | TEXT | — |
feelings | TEXT | — |
findings | TEXT | — |
future | TEXT | — |
created_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
code | TEXT | NOT NULL |
title | TEXT | NOT NULL |
mentor_user_id | TEXT | NOT NULL |
status | TEXT | NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby','live','ended')) |
game_question | INTEGER | — |
question_open | INTEGER | NOT NULL DEFAULT 0 |
question_started_at | TEXT | — |
created_at | TEXT | NOT NULL DEFAULT (datetime('now')) |
started_at | TEXT | — |
ended_at | TEXT | — |
schedule_id | TEXT | — 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
learner_id | TEXT | NOT NULL REFERENCES learners(id) |
program | TEXT | NOT NULL |
enrolled_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
sent_count | INTEGER | NOT NULL DEFAULT 0 |
last_sent_at | TEXT | — |
stopped_at | TEXT | — |
stop_reason | TEXT | — |
| — | table constraint | PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
user_id | TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE |
title | TEXT | NOT NULL |
content | TEXT | NOT NULL |
created_at | TEXT | NOT NULL |
updated_at | TEXT | NOT NULL |
is_public | INTEGER | NOT NULL DEFAULT 0 — thêm ở 0295_markmaps_public.sql |
shared_at | TEXT | — 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
pain_slug | TEXT | NOT NULL |
need_code | TEXT | NOT NULL |
framework_code | TEXT | NOT NULL DEFAULT 'nemo' |
competency_code | TEXT | NOT NULL |
why | TEXT | — |
sort_order | INTEGER | NOT NULL DEFAULT 0 |
| — | table constraint | PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
pain_slug | TEXT | NOT NULL REFERENCES marlin_pains(slug) |
code | TEXT | NOT NULL |
label | TEXT | NOT NULL |
note | TEXT | — |
sort_order | INTEGER | NOT NULL DEFAULT 0 |
| — | table constraint | PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
pain_slug | TEXT | NOT NULL REFERENCES marlin_pains(slug) |
sort_order | INTEGER | NOT NULL |
quote | TEXT | NOT NULL |
| — | table constraint | PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
slug | TEXT | PRIMARY KEY |
title | TEXT | NOT NULL |
summary | TEXT | — |
jtbd | TEXT | NOT NULL |
insight | TEXT | — |
sort_order | INTEGER | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
kind | TEXT | NOT NULL DEFAULT 'image' CHECK (kind IN ('image')) |
mime_type | TEXT | NOT NULL |
byte_size | INTEGER | NOT NULL |
width | INTEGER | — |
height | INTEGER | — |
alt_default | TEXT | — |
visibility | TEXT | NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','internal')) |
moderation_status | TEXT | NOT NULL DEFAULT 'approved' CHECK (moderation_status IN ('pending','approved','hidden','blocked')) |
uploaded_by | TEXT | REFERENCES users(id) |
created_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
domain | TEXT | NOT NULL |
title | TEXT | NOT NULL |
message | TEXT | — |
lang | TEXT | NOT NULL DEFAULT 'en' CHECK (lang IN ('en','vi')) |
visibility | TEXT | NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','owner','hidden')) |
pages_json | TEXT | NOT NULL DEFAULT '[]' |
script_json | TEXT | NOT NULL |
prompt | TEXT | — |
voice | TEXT | — |
src | TEXT | — |
notes | TEXT | — |
duration_s | REAL | — |
rendered_at | TEXT | — |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
script_hash | TEXT | — 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 < chứ không <=. 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
code | TEXT | PRIMARY KEY |
kind | TEXT | NOT NULL CHECK (kind IN ('base', 'offer')) |
label_vi | TEXT | NOT NULL |
amount_vnd | INTEGER | NOT NULL CHECK (amount_vnd >= 0) |
months | INTEGER | CHECK (months IS NULL OR months > 0) |
terms_vi | TEXT | — |
starts_at | TEXT | — |
ends_at | TEXT | — |
seq | INTEGER | NOT NULL DEFAULT 1 |
active | INTEGER | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
user_id | TEXT | UNIQUE REFERENCES users(id) |
kind | TEXT | NOT NULL DEFAULT 'mentor' CHECK (kind IN ('mentor','advisor')) |
full_name | TEXT | NOT NULL |
short_name | TEXT | — |
headline | TEXT | — |
photo_media_id | TEXT | REFERENCES media(id) |
current_org | TEXT | — |
subjects_json | TEXT | NOT NULL DEFAULT '[]' |
years_experience | INTEGER | — |
bio | TEXT | — |
bio_parent | TEXT | — |
bio_student | TEXT | — |
alumni_school_id | TEXT | — |
alumni_school | TEXT | — |
alumni_school_short | TEXT | — |
alumni_track | TEXT | — |
alumni_grad_year | INTEGER | — |
alumni_verified | INTEGER | NOT NULL DEFAULT 0 CHECK (alumni_verified IN (0,1)) |
alumni_verified_at | TEXT | — |
alumni_verified_by | TEXT | REFERENCES users(id) |
alumni_note | TEXT | , -- NỘI BỘ, không bao giờ ra API công khai (SDD-019 §4) |
status | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')) |
display_order | INTEGER | NOT NULL DEFAULT 100 |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
slug | TEXT | — thêm ở 0070_mentor_slug.sql |
website_url | TEXT | — thêm ở 0077_mentor_links.sql |
substack_url | TEXT | — thêm ở 0077_mentor_links.sql |
facebook_url | TEXT | — thêm ở 0077_mentor_links.sql |
card_tag | TEXT | — thêm ở 0247_mentor_card_tag.sql |
expertise_bio | TEXT | — thêm ở 0248_mentor_split_bio.sql |
personal_bio | TEXT | — 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
mentor_user_id | TEXT | REFERENCES users(id) |
subject | TEXT | NOT NULL DEFAULT 'ielts' |
weekday | INTEGER | NOT NULL CHECK (weekday BETWEEN 0 AND 6) |
start_time | TEXT | NOT NULL, -- 'HH:MM' giờ Việt Nam |
end_time | TEXT | NOT NULL |
capacity | INTEGER | NOT NULL DEFAULT 6 CHECK (capacity >= 1) |
meeting_url | TEXT | — |
note | TEXT | — |
status | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')) |
effective_from | TEXT | — |
effective_to | TEXT | — |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
updated_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
learner_id | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
amount | INTEGER | NOT NULL |
kind | TEXT | NOT NULL CHECK (kind IN ('weekly_streak','monthly_rank','manual')) |
ref | TEXT | NOT NULL DEFAULT '' |
note | TEXT | NOT NULL DEFAULT '' |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
paid_at | TEXT | — |
paid_by | TEXT | REFERENCES 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
learner_id | TEXT | PRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE |
balance | INTEGER | NOT NULL DEFAULT 0 |
updated_at | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
org_id | TEXT | NOT NULL REFERENCES outreach_orgs(id) ON DELETE CASCADE |
channel | TEXT | NOT NULL CHECK (channel IN ('email', 'facebook_inbox', 'linkedin')) |
kind | TEXT | NOT NULL DEFAULT 'initial' CHECK (kind IN ('initial', 'reminder')) |
template_id | TEXT | — |
language | TEXT | — |
to_address | TEXT | — |
subject | TEXT | — |
status | TEXT | NOT NULL CHECK (status IN ('sending', 'sent', 'failed', 'skipped', 'logged')) |
message_id | TEXT | — |
error | TEXT | — |
sent_by | TEXT | — |
sent_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
vn_date | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
key | TEXT | PRIMARY KEY, -- chữ thường: 'info@school.edu' hoặc 'school.edu' |
scope | TEXT | NOT NULL CHECK (scope IN ('email', 'domain')) |
reason | TEXT | — |
created_by | TEXT | — |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
outreach_orgs
── Outreach ──────────────────────────────────────────────────────────────────────────────────
migration: 0308_school_codes_and_outreach.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
country | TEXT | NOT NULL, -- ISO 3166-1 alpha-2, chữ HOA |
language | TEXT | NOT NULL DEFAULT 'en', -- ngôn ngữ thư: vi, en, id, th, ... |
type | TEXT | NOT NULL DEFAULT 'school' |
| — | table constraint | CHECK (type IN ('school', 'association', 'ministry', 'department', 'council', 'other')) |
name_local | TEXT | NOT NULL |
name_en | TEXT | — |
city | TEXT | — |
website | TEXT | — |
email | TEXT | , -- chỉ email CHÍNH THỨC công khai, chữ thường |
facebook_url | TEXT | — |
linkedin_url | TEXT | — |
source_urls | TEXT | , -- mảng JSON các URL chứng minh email/trang |
confidence | TEXT | NOT NULL DEFAULT 'medium' CHECK (confidence IN ('high', 'medium', 'low')) |
notes | TEXT | — |
contact_greeting | TEXT | , -- ghi đè câu chào mặc định của khuôn thư |
status | TEXT | NOT NULL DEFAULT 'new' |
| — | table constraint | CHECK (status IN ('new', 'contacted', 'replied', 'interested', 'joined', 'declined', 'opted_out', 'bounced')) |
approved | INTEGER | NOT NULL DEFAULT 0 CHECK (approved IN (0, 1)) |
approved_by | TEXT | — |
approved_at | TEXT | — |
school_code | TEXT | — |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Index: uq_outreach_orgs_email(email) UNIQUE · idx_outreach_orgs_country(country, status)