Skip to content

Data Dictionary · Khác (phần 1/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. 28 bảng, ai_teen_content_docs tới content_authoring_log.

ai_teen_content_docs ​

The current draft of each document. draft_rev goes up by one on every save; a save carrying an older rev is refused (409 STALE_DRAFT) so two editors cannot silently overwrite each other. live_version is the version in the course's live release (NULL = never released).

migration: 0338_ai_teen_content_editor.sql

CộtKiểuRàng buộc / ghi chú
doc_keyTEXTPRIMARY KEY
kindTEXTNOT NULL CHECK (kind IN ('program','course','unit','lesson'))
course_idTEXT—
schema_versionINTEGERNOT NULL DEFAULT 1
draft_jsonTEXTNOT NULL
draft_revINTEGERNOT NULL DEFAULT 1
draft_updated_byTEXT—
draft_updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
live_versionINTEGER—

Index: idx_ai_teen_content_docs_course(course_id, kind)

ai_teen_content_editors ​

Editor grants (SDD-056 §4). The design asked for a role_assignments row with scope_type='ai_teen_content', but that column carries a CHECK constraint from migration 0001 ('global','school','family','learner'), and widening it means rebuilding the table every session reads on every request. A separate grant table keeps the same meaning (a mentor plus a scope) with no rebuild. Staff and admin edit without a row here.

migration: 0338_ai_teen_content_editor.sql

CộtKiểuRàng buộc / ghi chú
user_idTEXTPRIMARY KEY REFERENCES users(id)
granted_byTEXT—
granted_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: user_id → users

ai_teen_content_import ​

Staging for the seed exporter (scripts/aiteen/export-content.mjs): each document's JSON is written here ONCE, then set-based statements copy it into drafts and versions without clobbering an unreleased Dolphin edit. The seed deletes its rows at the end; the table is normally empty.

migration: 0338_ai_teen_content_editor.sql

CộtKiểuRàng buộc / ghi chú
doc_keyTEXTPRIMARY KEY
kindTEXTNOT NULL
course_idTEXT—
jsonTEXTNOT NULL
fingerprintTEXTNOT NULL

ai_teen_content_live ​

Which release each course runs. Rollback = point this row at an older release; nothing is deleted.

migration: 0338_ai_teen_content_editor.sql

CộtKiểuRàng buộc / ghi chú
course_idTEXTPRIMARY KEY
release_idTEXTNOT NULL
updated_byTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

ai_teen_content_releases ​

A release is a whole course (SDD-056 §3): manifest_json maps every doc_key of the course to the version it ships. Releasing per course means a learner never sees a unit whose lesson list changed while the new lesson is unreleased.

migration: 0338_ai_teen_content_editor.sql

CộtKiểuRàng buộc / ghi chú
release_idTEXTPRIMARY KEY
course_idTEXTNOT NULL
manifest_jsonTEXTNOT NULL
created_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
noteTEXT—
validator_versionTEXTNOT NULL

Index: idx_ai_teen_content_releases_course(course_id, created_at)

ai_teen_content_versions ​

Every released state of a document. INSERT ONLY: the API has no UPDATE or DELETE on this table, so the diff between any two versions can always be rebuilt (SDD-056 §8).

migration: 0338_ai_teen_content_editor.sql

CộtKiểuRàng buộc / ghi chú
doc_keyTEXTNOT NULL
versionINTEGERNOT NULL
jsonTEXTNOT NULL
fingerprintTEXTNOT NULL
created_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
noteTEXT—
—table constraintPRIMARY KEY (doc_key, version)

ai_teen_enrollments ​

Migration 0285 - ghi danh chương trình AI Teen. SRC-1005. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 23.09.2026: learner vào learn.nemo12.com/ai-teen, đăng nhập, rồi ENROLL vào từng course (AI Teen 1, 2, 3). Bảng course_enrollments (0074) không dùng được: nó là lớp CÓ LỊCH, khoá ngoại tới courses có giờ học và sức chứa, và có cột enrolled_by_role cho bố mẹ. Nội dung AI Teen chưa nằm trong D1 (xem SDD-043 §2), nên khoá ngoại tới một bảng course ở đây sẽ là khoá ngoại tới hư không. course_id vì thế là chuỗi, và API kiểm nó với danh sách course của chương trình trước khi ghi. ## Chạy lại không đổi gì Chỉ có CREATE ... IF NOT EXISTS.

migration: 0285_ai_teen_enrollments.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id)
course_idTEXTNOT NULL
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','cancelled'))
enrolled_by_user_idTEXTNOT NULL REFERENCES users(id)
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (learner_id, course_id)

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

Index: idx_ai_teen_enrollments_course(course_id, status)

ai_teen_exercise_results ​

Migration 0309 - kết quả bài tập AI Teen. SRC-1118. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 28.09.2026: lưu kết quả bài tập của learner trong các lesson AI Teen. Trước đó câu trả lời và dấu tick chỉ sống trong trang, rời lesson là mất. Một dòng là trạng thái MỚI NHẤT của một bài tập: trắc nghiệm giữ phương án vừa chọn và đúng/sai, thực hành giữ các mục checklist đã tick. Không giữ lịch sử từng lần thử: chưa có ai cần đọc nó, và một bảng lịch sử lớn lên mỗi lần bấm là cái giá trả trước cho một câu hỏi chưa ai hỏi. Nội dung bài tập nằm ở phía learn (SDD-043 §2), nên bài tập được gọi bằng vị trí (course, unit, lesson, exercise) chứ không có khoá ngoại. ## Chạy lại không đổi gì Chỉ có CREATE ... IF NOT EXISTS.

migration: 0309_ai_teen_exercise_results.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id)
course_idTEXTNOT NULL
unit_noINTEGERNOT NULL CHECK (unit_no BETWEEN 1 AND 20)
lesson_noINTEGERNOT NULL CHECK (lesson_no BETWEEN 1 AND 20)
exercise_noINTEGERNOT NULL CHECK (exercise_no BETWEEN 1 AND 20)
kindTEXTNOT NULL CHECK (kind IN ('choice','practice'))
choice_indexINTEGER—
correctINTEGERCHECK (correct IN (0,1))
checkedTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (learner_id, course_id, unit_no, lesson_no, exercise_no)

Khóa ngoại: learner_id → learners

ai_teen_journeys ​

Migration 0333 - Trạng thái hành trình AI Teen (SRC-1251). ## Vì sao migration này tồn tại Trang learn.nemo12.com/ai-teen chuyển sang kiến trúc Journey-first (chỉ đạo chủ dự án 06.10.2026): cấp chương trình có ba chặng GOAL → WHERE AM I? → LEARNING, cấp unit có năm chặng GOAL → LEARN → PRACTICE → ASSESS → REFLECT. Learner phải quay lại đúng chỗ đang đứng ở máy khác, và mục tiêu, level tự đánh giá, reflection là bằng chứng mà Learner Model sẽ đọc về sau; để ở trình duyệt thì mất cả hai. Kết quả bài tập từng câu đã có bảng riêng (0309), bảng này chỉ giữ phần hành trình. MỘT DÒNG MỖI (learner, scope), nội dung là JSON: scope='program' cho hành trình cấp chương trình, scope='<course>:u<n>' cho một unit. Hình dạng JSON do server kiểm bằng zod trước khi ghi (workers/api/src/modules/aiTeen/journey.ts); tách thành cột thì mỗi lần chỉnh hành trình là một migration, trong khi chưa ai đọc từng trường bằng SQL. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS.

migration: 0333_ai_teen_journeys.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id)
scopeTEXTNOT NULL CHECK (scope = 'program' OR scope GLOB 'ai-teen-[0-9]:u[0-9]')
state_jsonTEXTNOT NULL CHECK (json_valid(state_json) AND length(state_json) <= 32000)
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (learner_id, scope)

Khóa ngoại: learner_id → learners

ai_teen_slot_registry ​

The slot registry of SDD-043 §12.4: what each lesson slot held when first released (its exercise and reading-part fingerprint). A slot whose fingerprint changes would attach learners' saved answers to different questions, so the rules refuse it. Seeded from apps/learn/src/aiTeen/data/legacy-slots.json.

migration: 0338_ai_teen_content_editor.sql

CộtKiểuRàng buộc / ghi chú
course_idTEXTNOT NULL
unit_noINTEGERNOT NULL
slotINTEGERNOT NULL
fingerprintTEXTNOT NULL
first_released_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
retired_atTEXT—
—table constraintPRIMARY KEY (course_id, unit_no, slot)

ai_teen_step_ticks ​

Migration 0339 - ai teen step ticks. SRC-1283, REQ-LRN-48, SDD-043 §15.3. ## Vì sao migration này tồn tại Learner AI Teen tự tick từng bước "self" của lesson (câu hỏi mở đầu, Mang theo, Tiếp theo...) là đã làm xong; tick phải còn khi đổi máy, nên nằm ở server. Một dòng = một bước đang được tick, bỏ tick là xoá dòng (không giữ lịch sử). Bảng riêng chứ không nhét vào blob JSON của ai_teen_journeys như cổng đọc: tick ghi dày và hay từ hai máy cùng lúc, một blob mỗi unit nghĩa là đọc-sửa-ghi cả blob và lần tick sau đè mất lần trước; một dòng mỗi tick là UPSERT/DELETE nguyên tử, và "k/N bước" của mentor là một câu COUNT. Khoá là slot lesson (không phải vị trí), id Activity và id Bước khai trong nội dung, nên đổi thứ tự lesson hay đổi tên hiển thị không làm tick gắn sang chỗ khác. Bước nào tick được do bản kê sinh từ nội dung quyết định ở API (stepCatalog.ts), không phải ở đây. Người đọc: learn (card Activity của trang lesson), Dolphin (dòng "k/N bước" của trang lát cắt). Xoá learner xoá tick (CASCADE); rút course không xoá. ## Chạy lại không đổi gì Chỉ có CREATE TABLE IF NOT EXISTS và CREATE INDEX IF NOT EXISTS.

migration: 0339_ai_teen_step_ticks.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
course_idTEXTNOT NULL
unit_noINTEGERNOT NULL CHECK (unit_no BETWEEN 1 AND 20)
lesson_slotINTEGERNOT NULL CHECK (lesson_slot BETWEEN 1 AND 20)
activity_idTEXTNOT NULL CHECK (length(activity_id) BETWEEN 2 AND 32)
step_idTEXTNOT NULL CHECK (length(step_id) BETWEEN 2 AND 32)
ticked_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ticked_by_user_idTEXTNOT NULL
—table constraintPRIMARY KEY (learner_id, course_id, unit_no, lesson_slot, activity_id, step_id)

Khóa ngoại: learner_id → learners

Index: idx_ai_teen_step_ticks_learner_course(learner_id, course_id)

article_categories ​

migration: 0313_articles.sql

CộtKiểuRàng buộc / ghi chú
serviceTEXTNOT NULL REFERENCES article_services(service)
slugTEXTNOT NULL
kindTEXTNOT NULL CHECK (kind IN ('jtbd', 'journey'))
titleTEXTNOT NULL
audienceTEXTNOT NULL
descriptionTEXTNOT NULL
introTEXTNOT NULL
jobTEXT—
sort_orderINTEGERNOT NULL
—table constraintPRIMARY KEY (service, slug)

Khóa ngoại: service → article_services

article_services ​

Migration 0313 - articles. SRC-1132. ## Vì sao migration này tồn tại Chủ dự án 29.09.2026: mỗi dịch vụ (NEMO IELTS, NEMO SAT, NEMO SPEAK, NEMO GRAMMAR) có khoảng 100 bài viết chia năm category, và "toàn bộ nội dung của mọi bài viết đưa hết vào D1", mỗi bài một URL riêng trên nemo12.com. Worker nemo12-web đọc ba bảng này và dựng HTML cho từng URL. Ba bảng theo đúng ba tầng URL: /<service>/articles, /<service>/articles/<category>, /<service>/articles/<category>/<slug>. body_html được dựng SẴN lúc sinh file seed (scripts/articles/seed.mjs), không dựng lúc phục vụ: worker khỏi mang bộ đọc Markdown, và chữ người đọc thấy là đúng thứ cổng kiểm đã đọc. body_md vẫn giữ để xuất sang Substack và để dựng lại HTML khi đổi bộ dựng. figures_json là DỮ LIỆU hình minh hoạ; SVG sinh bằng thuật toán lúc phục vụ. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.

migration: 0313_articles.sql

CộtKiểuRàng buộc / ghi chú
serviceTEXTPRIMARY KEY CHECK (service IN ('ielts', 'sat', 'speaking', 'grammar'))
brandTEXTNOT NULL
base_pathTEXTNOT NULL UNIQUE
taglineTEXTNOT NULL
hub_titleTEXTNOT NULL
hub_descriptionTEXTNOT NULL
hub_introTEXTNOT NULL
cta_jsonTEXTNOT NULL
links_jsonTEXTNOT NULL DEFAULT '[]'
updated_atTEXTNOT NULL

articles ​

migration: 0313_articles.sql

CộtKiểuRàng buộc / ghi chú
serviceTEXTNOT NULL
categoryTEXTNOT NULL
slugTEXTNOT NULL
titleTEXTNOT NULL
descriptionTEXTNOT NULL
keywordTEXTNOT NULL
angleTEXTNOT NULL
sort_orderINTEGERNOT NULL
body_mdTEXTNOT NULL
body_htmlTEXTNOT NULL
figures_jsonTEXTNOT NULL DEFAULT '[]'
word_countINTEGERNOT NULL
published_atTEXTNOT NULL
updated_atTEXTNOT NULL
statusTEXTNOT NULL DEFAULT 'published' CHECK (status IN ('draft', 'published'))
—table constraintPRIMARY KEY (service, category, slug)

Khóa ngoại: service, category → article_categories.service, slug

Index: idx_articles_list(service, category, status, sort_order)

b21_areas ​

migration: 0174_b21_bilingual_frameworks.sql

CộtKiểuRàng buộc / ghi chú
framework_codeTEXTNOT NULL REFERENCES b21_frameworks(code)
codeTEXTNOT NULL
nameTEXTNOT NULL
name_viTEXT—
slugTEXTNOT NULL
sort_orderINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (framework_code, code)

Khóa ngoại: framework_code → b21_frameworks

b21_competencies ​

migration: 0174_b21_bilingual_frameworks.sql

CộtKiểuRàng buộc / ghi chú
framework_codeTEXTNOT NULL
codeTEXTNOT NULL
area_codeTEXTNOT NULL
nameTEXTNOT NULL
name_viTEXT—
slugTEXTNOT NULL
statementTEXT—
statement_viTEXT—
standardsTEXT—
sort_orderINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (framework_code, code)

Khóa ngoại: framework_code, area_code → b21_areas.framework_code, code

Index: idx_b21_competencies_area(framework_code, area_code, sort_order)

b21_frameworks ​

Bốn khung: Nemo (cho con) · Marlin (cho bố mẹ) · Dolphin (cho thầy cô) · Turtle (cho ban giám hiệu). Cả bốn đều có dòng ở đây kể cả khi CHƯA có nội dung, để trang chủ hiện đủ bốn cửa và nói thẳng cửa nào chưa mở. audience_vi là nhãn giao diện tiếng Việt, không phải nội dung khung.

migration: 0174_b21_bilingual_frameworks.sql

CộtKiểuRàng buộc / ghi chú
codeTEXTPRIMARY KEY
nameTEXTNOT NULL
name_viTEXT—
audience_viTEXT—
slugTEXTNOT NULL
sort_orderINTEGERNOT NULL DEFAULT 0

b21_indicators ​

migration: 0174_b21_bilingual_frameworks.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
level_idTEXTNOT NULL REFERENCES b21_levels(id)
ordinalINTEGERNOT NULL
statementTEXTNOT NULL
statement_viTEXT—

Khóa ngoại: level_id → b21_levels

Index: idx_b21_indicators_level(level_id, ordinal)

b21_levels ​

id mang cả mã khung ("student:ELA.1.1@1") để chỉ báo của ba khung không bao giờ đụng nhau.

migration: 0174_b21_bilingual_frameworks.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
framework_codeTEXTNOT NULL
skill_codeTEXTNOT NULL
ordinalINTEGERNOT NULL
labelTEXTNOT NULL
label_viTEXT—
—table constraintUNIQUE (framework_code, skill_code, ordinal)

Khóa ngoại: framework_code, skill_code → b21_skills.framework_code, code

Index: idx_b21_levels_skill(framework_code, skill_code, ordinal)

b21_skills ​

migration: 0174_b21_bilingual_frameworks.sql

CộtKiểuRàng buộc / ghi chú
framework_codeTEXTNOT NULL
codeTEXTNOT NULL
competency_codeTEXTNOT NULL
nameTEXTNOT NULL
name_viTEXT—
slugTEXTNOT NULL
kindTEXTNOT NULL DEFAULT 'skill'
guiding_questionTEXT—
guiding_question_viTEXT—
standardsTEXT—
xq_crosswalkTEXT—
mywaysTEXT—
portraitTEXT—
sort_orderINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (framework_code, code)

Khóa ngoại: framework_code, competency_code → b21_competencies.framework_code, code

Index: idx_b21_skills_competency(framework_code, competency_code, sort_order)

band_pricing ​

Migration 0227 - Giá IELTS theo MỨC BAND TĂNG, sửa được từ admin (SRC-791). Chỉ đạo chủ dự án 2026-09-17: "Đưa giá theo band vào API để sửa từ admin." ## Vì sao cần một bảng riêng, không nhét vào pricing_items (0177) Hai bảng trả lời hai câu khác nhau, và nhét chung là ngày nào đó có người đọc nhầm: pricing_items MỘT KHOẢN ĐÁNG BAO NHIÊU. Học phí một khoá, gói bài tập, combo, thưởng. Mỗi dòng là một thứ mua được, giá đứng yên cho tới khi có người sửa. band_pricing MỘT ĐƠN GIÁ, VÀ NÓ CÓ HẠN DÙNG. Bao nhiêu tiền cho mỗi 1.0 band tăng thêm. Giao diện nhân đơn giá này với mức band người đọc chọn. Điểm khác thật sự nằm ở cột ends_at. pricing_items không có khái niệm hết hạn: muốn ngừng một khoản thì có người hạ cờ active. Ở đây khuyến mãi phải TỰ rụng đúng ngày, vì một dòng "giảm 50% trong năm 2026" còn nằm trên trang ngày 01.01.2027 là một lời chào hàng sai, và nó sai im lặng - không cổng nào kêu, chỉ có người đọc tin rồi hỏi lại. Thêm cột hạn vào pricing_items thì tám dòng không bao giờ hết hạn phải mang theo hai cột luôn NULL. ## kind: một dòng nền, nhiều dòng khuyến mãi base Mức thường. Đúng MỘT dòng active, không có hạn. Đây là con số bị gạch ngang khi có khuyến mãi, nên nó phải luôn tồn tại; API từ chối trả về khối giá nếu thiếu nó. offer Một đợt giảm, có ends_at. Hết hạn thì API bỏ qua, không cần ai đụng vào. Đợt nào đang chạy khi có nhiều đợt chồng nhau: chọn amount_vnd NHỎ NHẤT còn hiệu lực. Cố ý không xếp theo seq hay ngày tạo - người nhập không phải nhớ thứ tự, và người học luôn được mức tốt nhất. Đây là quy tắc đã ghi ở pickOffer() trong service; hai nơi nói cùng một câu. ## Ngày lưu dạng ISO 8601 có múi giờ TEXT chứ không INTEGER: đọc thẳng được khi mở bảng ra xem, và so sánh chuỗi ISO cùng định dạng cho ra đúng thứ tự thời gian. Bắt buộc ghi kèm offset (+07:00), nếu không thì "hết ngày 31.10.2026" nghĩa là hết lúc nào còn tuỳ máy nào đọc. ends_at là thời điểm HẾT hiệu lực, so sánh bằng &lt; chứ không &lt;=: ghi 2026-11-01T00:00:00+07:00 nghĩa là bán hết ngày 31.10.2026, và không ai phải nghĩ xem 23:59:59 có còn kịp không.

migration: 0227_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)
max_months_per_bandINTEGERCHECK (max_months_per_band IS NULL OR max_months_per_band > 0)
terms_viTEXT—
starts_atTEXT—
ends_atTEXT—
seqINTEGERNOT NULL DEFAULT 1
activeINTEGERNOT NULL DEFAULT 1

Index: idx_band_pricing_active(active, kind, seq)

blueprint_weights ​

---------------------------------------------------------------- blueprint_weights

migration: 0082_english_state_enums.sql

CộtKiểuRàng buộc / ghi chú
blueprint_idTEXTNOT NULL REFERENCES blueprints(id)
subject_idTEXTNOT NULL
node_idTEXTNOT NULL
weightREALNOT NULL
required_stateTEXTNOT NULL CHECK (required_state IN
—table constraintPRIMARY KEY (blueprint_id, node_id)

Khóa ngoại: blueprint_id → blueprints

calendar_feed_tokens ​

---- 3. Feed ICS ---- Nhắc lịch là việc app lịch của người dùng làm tốt hơn ta rất nhiều (đẩy điện thoại, nhắc trước 10 phút, hiện trên đồng hồ). Nên ta KHÔNG viết bộ nhắc riêng, cũng không xin quyền Google Calendar API: ta phát một địa chỉ ICS một chiều, người dùng dán vào Google/Apple Calendar một lần. Một chiều nên không có bài toán đồng bộ ngược, và không tài khoản Google nào phải cấp quyền. token là bí mật mang quyền đọc lịch của đúng một người, vì URL feed không có cookie đi kèm — app lịch gọi bằng máy chủ của nó. Vì vậy: token dài, sinh ngẫu nhiên, KHÔNG chứa user_id, và rút lại được (revoked_at) khi người dùng lỡ chia sẻ nhầm địa chỉ.

migration: 0201_calendar.sql

CộtKiểuRàng buộc / ghi chú
tokenTEXTPRIMARY KEY
user_idTEXTNOT NULL REFERENCES users(id)
labelTEXTNOT NULL DEFAULT '', -- "iPhone của mẹ" — để biết rút cái nào khi cần
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
last_used_atTEXT, -- app lịch gọi lần cuối lúc nào; rỗng lâu = người dùng bỏ
revoked_atTEXT—

Khóa ngoại: user_id → users

Index: idx_calendar_feed_user(user_id, revoked_at)

certificate_codes ​

Migration 0344 - certificate codes. SRC-1333, SDD-004 §20f. ## Vì sao migration này tồn tại Chứng chỉ (SRC-1331) được SUY RA, không lưu, và mã xác minh NEMO- + 10 hex là một băm một chiều của (learner, course, earned_at). Tra cứu công khai theo mã (SRC-1333) vì thế không thể đi ngược từ mã về learner; không có bảng này thì mỗi lượt tra phải suy lại chứng chỉ của MỌI learner. Bảng là một CHỈ MỤC, không phải nguồn sự thật: một dòng được ghi lười khi chứng chỉ đã đạt được hiện ra cho chủ của nó (learner hoặc phụ huynh), tức là đúng lúc mã lần đầu có người nhìn thấy. Lúc tra, server suy lại chứng chỉ của đúng learner trong dòng và chỉ trả valid khi mã còn khớp, nên dòng cũ (tiến độ bị sửa, khoá rút khỏi live) tự thành invalid mà không cần xoá. ## Chạy lại không đổi gì CREATE TABLE / INDEX IF NOT EXISTS.

migration: 0344_certificate_codes.sql

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

Khóa ngoại: learner_id → learners

Index: idx_certificate_codes_learner(learner_id)

chat_testimonials ​

Migration 0320 - testimonial dạng ẢNH CHỤP CHAT (Zalo group, Zoom). SRC-1169. ## Vì sao migration này tồn tại Mentor muốn đăng ảnh chụp màn hình các đoạn chat học viên khoe kết quả, cảm ơn, kể tiến bộ, rồi cho chúng hiện thành lưới trên /students/<tag> (ielts, speak, grammar, sat). Bảng testimonials (0075 → 0216) mô tả LỜI TRÍCH có cổng verified do người biên tập bật; loại này khác ở ba chỗ nên có bảng riêng: một bản ghi có tới SÁU ảnh, chọn trang bằng TAG cố định, và mentor TỰ ĐĂNG (chỉ đạo 30.09.2026) - cổng thay cho "verified" là hai cam kết của chính mentor: đã xin phép học viên, và đã che thông tin của người khác trong ảnh. Trần 200KB mỗi ảnh (chỉ đạo 30.09.2026) KHÔNG nằm ở bảng này mà ở API: dolphin tự nén trước khi tải lên, còn API từ chối gắn một media có byte_size > 204800 vào testimonial. ## Chạy lại không đổi gì Chỉ có CREATE ... IF NOT EXISTS.

migration: 0320_chat_testimonials.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
images_jsonTEXTNOT NULL DEFAULT '[]'
captionTEXTNOT NULL, -- một dòng dưới ảnh trên lưới
descriptionTEXT, -- đoạn dài hơn, chỉ hiện trong lightbox
learner_nameTEXTNOT NULL, -- được phép viết tắt: "Minh A."
learner_infoTEXT, -- "Lớp 11, THPT Chu Văn An · IELTS 5.5 → 7.0"
said_onTEXT, -- YYYY-MM-DD, ngày của đoạn chat; trang hiện DD.MM.YYYY
sourceTEXTNOT NULL DEFAULT 'zalo', -- zalo | zoom | other; cổng ở zod, không CHECK
tags_jsonTEXTNOT NULL DEFAULT '[]', -- ["ielts","speak"]; danh sách cố định trong API
consentINTEGERNOT NULL DEFAULT 0, -- mentor cam kết đã xin phép học viên
redactedINTEGERNOT NULL DEFAULT 0, -- mentor cam kết đã che tên/avatar/sđt người khác
featuredINTEGERNOT NULL DEFAULT 0, -- ghim lên đầu lưới
statusTEXTNOT NULL DEFAULT 'draft', -- draft | published
created_byTEXTREFERENCES users(id)
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: created_by → users

Index: idx_chat_testimonials_public(status, featured DESC, said_on DESC)

community_event_registrations ​

Một dòng cho mỗi (sự kiện, người). Huỷ đăng ký GIỮ dòng lại với status='cancelled' thay vì xoá: cần biết ai từng giữ chỗ rồi bỏ, và để đăng ký lại không sinh dòng thứ hai.

migration: 0039_community_events.sql

CộtKiểuRàng buộc / ghi chú
event_idTEXTNOT NULL
user_idTEXTNOT NULL
statusTEXTNOT NULL CHECK (status IN ('joined', 'cancelled'))
display_nameTEXT, -- chụp lại tên lúc đăng ký để danh sách không phải join users
noteTEXT, -- ghi chú của người đăng ký, vd giờ có mặt nếu tới muộn
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
—table constraintPRIMARY KEY (event_id, user_id)

Khóa ngoại: event_id → community_events.id

Index: idx_community_event_reg_user(user_id, status) · idx_community_event_reg_event(event_id, status)

community_events ​

Sự kiện cộng đồng cho Marlin (SRC-122, REQ-PAR-16) — cổng phụ huynh marlins.nemo12.com. Kế thừa portal_events / portal_event_registrations của legacy family.chuyenchon.com (xem docs/architecture/legacy-migration.md) nhưng khoá theo user_id của Nemo12 thay vì email: session Nemo12 đã có user_id, còn email có thể đổi và không phải khoá chính ở đâu cả. Không tách metadata / nội dung như labs: một sự kiện chỉ vài KB và danh sách sự kiện sắp diễn ra chưa bao giờ quá vài chục dòng, nên đọc một bảng là đủ nhẹ.

migration: 0039_community_events.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY, -- slug người đọc được, dùng thẳng trên URL (#/events/{id})
seriesTEXTNOT NULL DEFAULT '', -- tên chuỗi sự kiện, vd "Cafe cùng Nemo12"
titleTEXTNOT NULL
summaryTEXTNOT NULL DEFAULT '', -- một câu cho thẻ danh sách
goalTEXTNOT NULL DEFAULT '', -- khối "Mục tiêu" ở trang chi tiết, xuống dòng = một đoạn
audienceTEXT, -- "Buổi này dành cho ai"
agenda_jsonTEXT, -- [{"t":"15:00","label":"…"}] — lịch trình trong buổi
notesTEXT, -- lưu ý trước khi đến, mỗi dòng một gạch đầu dòng
starts_atTEXTNOT NULL, -- ISO 8601 CÓ offset (…+07:00) — client tự đổi sang giờ máy
duration_minINTEGERNOT NULL DEFAULT 90
modeTEXTNOT NULL DEFAULT 'offline' CHECK (mode IN ('offline', 'online'))
venueTEXT—
addressTEXT—
capacityINTEGER, -- NULL = không giới hạn chỗ
feeTEXT—
fee_noteTEXT—
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
host_user_idTEXT— thêm ở 0215_event_interactions.sql
youtube_video_idTEXTCHECK (youtube_video_id IS NULL OR (
length(youtube_video_id) = 11
AND youtube_video_id GLOB '[A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-][A-Za-z0-9_-]'

)) — thêm ở 0219_event_video.sql | | video_caption | TEXT | CHECK (video_caption IS NULL OR length(video_caption) <= 200) — thêm ở 0219_event_video.sql | | meeting_url | TEXT | — thêm ở 0236_ielts_intro_event.sql | | brand | TEXT | — thêm ở 0341_community_events_brand.sql |

Index: idx_community_events_starts(starts_at)

content_authoring_log ​

Migration 0179 — sổ lý do của xưởng sinh nội dung (SRC-642). VÌ SAO CẦN MỘT CÁI BẢNG chứ không chỉ logEvent: log của Worker chỉ đọc được qua wrangler tail, và tail là một kênh SỐNG — mất kết nối, lỗi mạng phía máy, hay đơn giản là không ai đang mở nó lúc lượt cron chạy, thì lý do biến mất vĩnh viễn. Đã trả giá đúng chuyện này trong ngày: ba vòng chẩn đoán mất trắng vì tail lúc được lúc không, và mỗi vòng là một nhịp cron nữa trôi. Bảng này ghi lượt nào từ chối cái gì và VÌ SAO, đọc bằng một câu SELECT. Nó KHÔNG thay log: log vẫn ghi đủ cho ai đang xem trực tiếp. Nó thay chỗ dựa. Giữ gọn: mỗi đối tượng chỉ giữ dòng mới nhất (khoá chính là stage+target), vì thứ cần biết là "lần thử gần nhất hỏng vì gì", không phải toàn bộ lịch sử thất bại.

migration: 0179_authoring_log.sql

CộtKiểuRàng buộc / ghi chú
stageTEXTNOT NULL
target_idTEXTNOT NULL
outcomeTEXTNOT NULL CHECK (outcome IN ('rejected','failed'))
reasonTEXTNOT NULL
sampleTEXT—
atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (stage, target_id)