Skip to content

Data Dictionary · Khác (phần 3/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. 34 bảng, curriculum_lesson_interventions tới email_suppressions.

curriculum_lesson_interventions ​

migration: 0086_course_specs_cp_english.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
seqINTEGERNOT NULL
evidence_viTEXTNOT NULL
action_viTEXTNOT NULL
—table constraintPRIMARY KEY (lesson_id, seq)

Khóa ngoại: lesson_id → curriculum_lessons

curriculum_lesson_materials ​

Học liệu đầu bài. kind quyết định cách mở: youtube/pdf/link → url; lab → ref_id là id lab trong thư viện Lab đã có (SRC-166). Ràng buộc ép đúng điều đó ở TẦNG DỮ LIỆU: một lab không có url, một video không có ref_id — sai chỗ này thì UI không biết mở bằng cách nào, và nó sẽ hỏng lặng lẽ. reflection_prompt_vi: chỉ đạo "sau khi xem xong thì học sinh cần làm reflection, hoặc report, để thể hiện là đã xem rồi, và rút ra cái gì đó". Để NULL là học liệu đọc thêm, không đòi nộp gì.

migration: 0085_curriculum_outcomes.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
seqINTEGERNOT NULL
kindTEXTNOT NULL CHECK (kind IN ('youtube','pdf','lab','link'))
title_viTEXTNOT NULL
urlTEXT—
ref_idTEXT—
minutesINTEGER—
reflection_prompt_viTEXT—
—table constraintPRIMARY KEY (lesson_id, seq)
—table constraintCHECK ((kind = 'lab' AND ref_id IS NOT NULL AND url IS NULL)

Khóa ngoại: lesson_id → curriculum_lessons

curriculum_lesson_misconceptions ​

migration: 0086_course_specs_cp_english.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
seqINTEGERNOT NULL
text_viTEXTNOT NULL
unit_codeTEXT—
—table constraintPRIMARY KEY (lesson_id, seq)
text_enTEXT— thêm ở 0194_lesson_content_bilingual.sql

Khóa ngoại: lesson_id → curriculum_lessons

curriculum_lesson_outcomes ​

── Lesson ────────────────────────────────────────────────────────────────────────────────────── Cùng dạng "Tôi có thể…" nhưng ở mức nhận biết/hiểu, không phải mức làm ra sản phẩm.

migration: 0085_curriculum_outcomes.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
seqINTEGERNOT NULL
text_viTEXTNOT NULL
—table constraintPRIMARY KEY (lesson_id, seq)
text_enTEXT— thêm ở 0191_bilingual_outcome_gaps.sql

Khóa ngoại: lesson_id → curriculum_lessons

curriculum_lesson_questions ​

migration: 0181_curriculum_bilingual.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
seqINTEGERNOT NULL
text_viTEXT—
text_enTEXT—
—table constraintPRIMARY KEY (lesson_id, seq)

Khóa ngoại: lesson_id → curriculum_lessons

curriculum_lesson_quiz ​

Migration 0204 — Bộ 20 câu trắc nghiệm cuối mỗi lesson của khoá bố mẹ (SRC-680 đợt 3). VÌ SAO CẦN BẢNG MỚI. Thân bài lesson của bố mẹ có bốn phần: SCQA, ví dụ, chỗ dễ hiểu lầm, và một bộ trắc nghiệm. Ba phần đầu đã có chỗ trong tầng Course: SCQA → curriculum_lesson_stories (situation/complication/question/resolution) ví dụ → curriculum_lesson_examples kind='example' chỗ hiểu lầm → curriculum_lesson_examples kind='near_miss' (looksLike → text, whyWrong → why) Phần thứ tư thì không: curriculum_lesson_checks giữ ĐỀ BÀI kiểm tra chứ không giữ phương án và đáp án, còn items là ngân hàng câu hỏi cho học sinh, gắn với knowledge node chứ không gắn với lesson. Nhét trắc nghiệm của bố mẹ vào items là trộn hai thứ có vòng đời khác hẳn nhau: câu trong items được máy sinh, máy chấm, người duyệt; câu ở đây do chủ dự án viết và đọc cùng bài.

migration: 0204_lesson_quiz.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
seqINTEGERNOT NULL
stem_viTEXTNOT NULL
stem_enTEXT—
answerINTEGERNOT NULL CHECK (answer BETWEEN 0 AND 3)
why_viTEXT—
why_enTEXT—
—table constraintPRIMARY KEY (lesson_id, seq)

Khóa ngoại: lesson_id → curriculum_lessons

Index: idx_lesson_quiz_lesson(lesson_id, seq)

curriculum_lesson_quiz_choices ​

Phương án tách bảng chứ không phải bốn cặp cột choice1_vi/choice1_en…: bốn cặp cột thì sửa một phương án phải biết nó là cột số mấy, và không câu SELECT nào đếm được "câu nào thiếu phương án". Một bảng thì cả hai việc đó là một dòng SQL.

migration: 0204_lesson_quiz.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL
quiz_seqINTEGERNOT NULL
seqINTEGERNOT NULL CHECK (seq BETWEEN 0 AND 3)
text_viTEXTNOT NULL
text_enTEXT—
—table constraintPRIMARY KEY (lesson_id, quiz_seq, seq)

Khóa ngoại: lesson_id, quiz_seq → curriculum_lesson_quiz.lesson_id, seq

curriculum_lesson_reflections ​

Bài Ngẫm của learner. Hai câu hỏi là CỐ ĐỊNH cho mọi bài ("con nhận ra điều gì đáng giữ lại" và "con sẽ làm khác đi thế nào, khi nào"), nên không có bảng đề bài: đề nằm trong giao diện, còn đây chỉ lưu câu trả lời. Một learner một bài đúng MỘT bản ghi, viết lại thì đè lên: đây là chỗ ngẫm chứ không phải nhật ký, và learner quay lại sửa cho đúng hơn là chuyện nên khuyến khích.

migration: 0162_curriculum_lesson_story_reflection.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
learner_idTEXTNOT NULL
findings_viTEXTNOT NULL
future_viTEXTNOT NULL
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (lesson_id, learner_id)

Khóa ngoại: lesson_id → curriculum_lessons

Index: idx_lesson_reflections_learner(learner_id, updated_at DESC)

curriculum_lesson_stories ​

── VÁ THỨ TỰ (2026-08-30, commit hộ phiên khác — không đụng nội dung của họ) ────────────── Bảng curriculum_lesson_stories được tạo trong 0176_curriculum_lesson_story_reflection.sql. File đó vốn mang số 0162 nên chạy TRƯỚC file này; khi nó được đổi số thành 0176 (SRC-648) thì thứ tự đảo lại, và trên một DB dựng mới file 0163 này chèn vào một bảng chưa tồn tại — "no such table: curriculum_lesson_stories". CI đỏ từ đó, chặn deploy của mọi phiên. Vá bằng cách khai lại bảng ở đây thay vì đổi số một trong hai file: · Đổi số 0163 là nguy hiểm — nó có thể đã áp lên production, mà tên file chính là khoá trong d1_migrations; đổi tên là wrangler chạy lại, và các INSERT dưới đây KHÔNG idempotent. · Đổi số 0176 xuống dưới 0163 thì không còn số trống nào để chen. IF NOT EXISTS nên chạy ở đâu, theo thứ tự nào cũng vô hại; 0176 giữ nguyên bản khai của nó.

migration: 0163_content_lesson_stories.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTPRIMARY KEY REFERENCES curriculum_lessons(id)
situation_viTEXTNOT NULL
complication_viTEXTNOT NULL
question_viTEXTNOT NULL
resolution_viTEXTNOT NULL
weakINTEGERNOT NULL DEFAULT 0 — thêm ở 0184_story_weak_flag.sql
situation_enTEXT— thêm ở 0194_lesson_content_bilingual.sql
complication_enTEXT— thêm ở 0194_lesson_content_bilingual.sql
question_enTEXT— thêm ở 0194_lesson_content_bilingual.sql
resolution_enTEXT— thêm ở 0194_lesson_content_bilingual.sql

Khóa ngoại: lesson_id → curriculum_lessons

curriculum_lesson_takeaways ​

Migration 0185 — Key Takeaways ở cuối mỗi Lesson và mỗi Unit (SRC-660). Chỉ đạo chủ dự án 2026-08-31: "Bổ sung Key Takeaways vào cuối các Lesson, và Units." HAI BẢNG CHỨ KHÔNG MỘT BẢNG CÓ CỘT scope: takeaway của lesson và của unit khác nhau về HẠNG, không chỉ khác chỗ treo. Takeaway của một bài là điều đọng lại sau một buổi; của một unit là điều còn lại sau khi quên hết các bài. Một bảng chung với cột phân loại thì hai thứ ấy trông như một, và câu truy vấn nào cũng phải nhớ lọc — quên một lần là trộn hai hạng vào nhau. Song ngữ ngay từ đầu (cùng quy ước _vi = _en nghĩa là chưa dịch, xem migration 0181): mạch 12 khoá viết bằng tiếng Anh, thêm cột sau thì lại phải đi vá một vòng nữa.

migration: 0185_key_takeaways.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
seqINTEGERNOT NULL
text_viTEXTNOT NULL
text_enTEXT—
—table constraintPRIMARY KEY (lesson_id, seq)

Khóa ngoại: lesson_id → curriculum_lessons

curriculum_lessons ​

migration: 0084_curriculum_courses.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
seqINTEGERNOT NULL
name_viTEXTNOT NULL
guiding_question_viTEXT—
concepts_viTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
name_enTEXT— thêm ở 0181_curriculum_bilingual.sql
guiding_question_enTEXT— thêm ở 0181_curriculum_bilingual.sql

Khóa ngoại: unit_id → curriculum_units

Index: idx_curriculum_lessons_slot(unit_id, seq) UNIQUE

curriculum_part_completions ​

Learner xác nhận đã xong từng CỤM bên trong một chặng (SRC-668). Chỉ đạo chủ dự án 2026-09-02: "Kéo tới đâu thì ở cuối cần có một button be bé vui vui để xác nhận" — và sau đó: "Mỗi cụm một nút luôn đi." VÌ SAO BẢNG RIÊNG chứ không thêm cột part vào curriculum_stage_completions: 1. Khoá chính ở bảng kia là (lesson_id, learner_id, stage), và SQLite không sửa được khoá chính — phải dựng bảng mới, chép dữ liệu, DROP rồi RENAME. Làm việc đó trên một bảng đang phục vụ learner, trong đúng khoảng thời gian mã mới đã deploy còn migration thì chưa chạy, là đánh cược không cần thiết. 2. Hai thứ này KHÁC NGHĨA nhau. "Xong chặng" là mốc tiến độ mà trang phụ huynh và báo cáo đọc. "Xong cụm" là nhịp đọc trong lúc học — nó nói con đã đi tới đâu trong một trang dài, không nói con đã học xong cái gì. Trộn hai thứ vào một bảng thì mọi câu đếm tiến độ phải nhớ lọc part = '', và sẽ có ngày một câu quên lọc. KHÔNG có cột undo: bấm lại lần nữa thì XOÁ dòng, cùng luật với bảng chặng — một chỉ báo chỉ biết tăng thì không còn là chỉ báo.

migration: 0193_lesson_part_completions.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id) ON DELETE CASCADE
learner_idTEXTNOT NULL
partTEXTNOT NULL
completed_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (lesson_id, learner_id, part)

Khóa ngoại: lesson_id → curriculum_lessons

Index: idx_part_completions_learner(learner_id, completed_at DESC)

curriculum_stage_completions ​

Migration 0180 — Learner xác nhận đã xong từng chặng của một bài (SRC-654). Chỉ đạo chủ dự án 2026-08-31: "Cuối mỗi phần đều có button để xác nhận là 'Tôi đã hoàn thành', click vào xong, thì hệ thống ghi nhận là Learner đã hoàn thành." VÌ SAO GHI THEO CHẶNG CHỨ KHÔNG PHẢI THEO BÀI: một bài có bốn chặng và learner thường bỏ dở giữa chừng. Chỉ ghi "xong bài" thì mọi thứ dở dang trông y hệt nhau — không biết con dừng ở chỗ đọc hay chỗ ngẫm, mà hai chỗ ấy cần hai cách giúp khác nhau. KHÔNG có cột undo: bấm lại lần nữa thì XOÁ dòng (xem service). Một bảng chỉ-thêm thì con số "đã xong" chỉ có thể phồng lên, và một chỉ báo chỉ biết tăng thì không còn là chỉ báo.

migration: 0180_lesson_stage_completions.sql

CộtKiểuRàng buộc / ghi chú
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id)
learner_idTEXTNOT NULL
stageTEXTNOT NULL
completed_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (lesson_id, learner_id, stage)

Khóa ngoại: lesson_id → curriculum_lessons

Index: idx_stage_completions_learner(learner_id, completed_at DESC)

curriculum_task_criteria ​

Tiêu chí chấm, mỗi dòng một tiêu chí. outcome_seq nối tiêu chí về đúng Unit Outcome mà nó đo — đây là chỗ vòng "outcome → task → chấm" khép lại; để trống nghĩa là tiêu chí chung (trình bày, thời hạn), vẫn hợp lệ.

migration: 0085_curriculum_outcomes.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_unit_tasks(unit_id)
seqINTEGERNOT NULL
text_viTEXTNOT NULL
outcome_seqINTEGER—
—table constraintPRIMARY KEY (unit_id, seq)
text_enTEXT— thêm ở 0191_bilingual_outcome_gaps.sql

Khóa ngoại: unit_id → curriculum_unit_tasks

curriculum_unit_concepts ​

Migration 0183 — Key Concepts + Skills cho Unit, ví dụ và ví dụ dễ nhầm cho Lesson (SRC-659). Chỉ đạo chủ dự án 2026-08-31: "Mỗi Unit, cần bổ sung Key Concepts, Skills" và "soạn SCQA cho Lesson, 3 ví dụ, 3 ví dụ dễ gây hiểu lầm". VÌ SAO BẢNG MỚI CHỨ KHÔNG DÙNG curriculum_unit_elements ĐANG CÓ: bảng ấy khoá cứng kind bằng CHECK (key_concept, material, framework, model, formula, principle) và KHÔNG có cột tiếng Anh. SQLite không sửa được CHECK, nên thêm 'skill' vào đó là phải dựng lại bảng và chép 800 dòng của 25 course cũ sang. Bảng mới rẻ hơn, và nó mang sẵn song ngữ mà mạch 12 khoá bắt buộc phải có.

migration: 0183_unit_concepts_lesson_examples.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
kindTEXTNOT NULL CHECK (kind IN ('key_concept', 'skill'))
seqINTEGERNOT NULL
label_viTEXTNOT NULL
label_enTEXT—
detail_viTEXT—
detail_enTEXT—
—table constraintPRIMARY KEY (unit_id, kind, seq)
example_viTEXT— thêm ở 0203_curriculum_audience.sql
example_enTEXT— thêm ở 0203_curriculum_audience.sql

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_elements ​

key_concept (3-5 mỗi unit) · material (2 ngữ liệu) · framework/model/formula/principle (2-3 khung tư duy).

migration: 0084_curriculum_courses.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
kindTEXTNOT NULL CHECK (kind IN ('key_concept','material','framework','model','formula','principle'))
seqINTEGERNOT NULL
label_viTEXTNOT NULL
detail_viTEXT—
—table constraintPRIMARY KEY (unit_id, kind, seq)

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_misconceptions ​

migration: 0086_course_specs_cp_english.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
codeTEXTNOT NULL
text_viTEXTNOT NULL
—table constraintPRIMARY KEY (unit_id, code)

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_outcomes ​

── Unit ──────────────────────────────────────────────────────────────────────────────────────── Outcome viết ở NGÔI THỨ NHẤT ("Tôi có thể…") đúng như chỉ đạo: chủ ngữ là learner, không phải chương trình. Câu "Học sinh nắm được…" mô tả việc dạy; "Tôi có thể…" mô tả việc học.

migration: 0085_curriculum_outcomes.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
seqINTEGERNOT NULL
text_viTEXTNOT NULL
—table constraintPRIMARY KEY (unit_id, seq)
text_enTEXT— thêm ở 0181_curriculum_bilingual.sql

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_prereq_notes ​

migration: 0086_course_specs_cp_english.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
seqINTEGERNOT NULL
text_viTEXTNOT NULL
—table constraintPRIMARY KEY (unit_id, seq)

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_questions ​

Một Unit có thể có NHIỀU Essential Question, một Lesson có nhiều Guiding Question (spec của chủ dự án 2026-08-31 cho 2 EQ mỗi unit và 3-4 GQ mỗi lesson). Cột đơn essential_question_vi giữ nguyên và luôn chứa câu THỨ NHẤT — mọi thứ đang đọc nó vẫn chạy — còn bảng này giữ đủ bộ. Không đặt khoá ngoại tới curriculum_units(id) trên bảng câu hỏi của lesson và ngược lại: hai bảng tách nhau vì hai tầng có vòng đời khác nhau (unit sống lâu hơn lesson, xem stageOrphans).

migration: 0181_curriculum_bilingual.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
seqINTEGERNOT NULL
text_viTEXT—
text_enTEXT—
—table constraintPRIMARY KEY (unit_id, seq)

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_specs ​

migration: 0086_course_specs_cp_english.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTPRIMARY KEY REFERENCES curriculum_units(id)
purpose_viTEXT—
progression_viTEXT—
mastery_criteria_viTEXT—

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_takeaways ​

migration: 0185_key_takeaways.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTNOT NULL REFERENCES curriculum_units(id)
seqINTEGERNOT NULL
text_viTEXTNOT NULL
text_enTEXT—
—table constraintPRIMARY KEY (unit_id, seq)

Khóa ngoại: unit_id → curriculum_units

curriculum_unit_tasks ​

Đúng MỘT Performance Task cho mỗi Unit (unit_id là khoá chính): nó là cửa ra của Unit, có hai cửa thì "hoàn thành Unit" mất nghĩa.

migration: 0085_curriculum_outcomes.sql

CộtKiểuRàng buộc / ghi chú
unit_idTEXTPRIMARY KEY REFERENCES curriculum_units(id)
title_viTEXTNOT NULL
prompt_viTEXTNOT NULL
product_viTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
title_enTEXT— thêm ở 0181_curriculum_bilingual.sql
prompt_enTEXT— thêm ở 0181_curriculum_bilingual.sql
product_enTEXT— thêm ở 0181_curriculum_bilingual.sql

Khóa ngoại: unit_id → curriculum_units

curriculum_units ​

migration: 0084_curriculum_courses.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
course_idTEXTNOT NULL REFERENCES curriculum_courses(id)
seqINTEGERNOT NULL
name_viTEXTNOT NULL
big_idea_viTEXT—
essential_question_viTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
name_enTEXT— thêm ở 0181_curriculum_bilingual.sql
big_idea_enTEXT— thêm ở 0181_curriculum_bilingual.sql
essential_question_enTEXT— thêm ở 0181_curriculum_bilingual.sql
compelling_question_viTEXT— thêm ở 0332_curriculum_units_compelling_question.sql
compelling_question_enTEXT— thêm ở 0332_curriculum_units_compelling_question.sql

Khóa ngoại: course_id → curriculum_courses

Index: idx_curriculum_units_slot(course_id, seq) UNIQUE

dictation_attempts ​

Mỗi lần learner bấm "Kiểm tra" ghi lại một dòng. Không phải để chấm điểm học bạ mà để biết câu nào cả lớp cùng sai — đó là dữ liệu nói lên ngữ liệu chỗ nào quá khó, thứ không đoán được.

migration: 0190_dictation.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
set_idTEXTNOT NULL REFERENCES dictation_sets(id) ON DELETE CASCADE
learner_idTEXTNOT NULL
scores_jsonTEXTNOT NULL, -- [{seq, percent}] — KHÔNG lưu chữ learner gõ
overallINTEGERNOT NULL
created_atTEXTNOT NULL DEFAULT (datetime('now'))

Khóa ngoại: set_id → dictation_sets

Index: idx_dictation_attempts_set(set_id, created_at) · idx_dictation_attempts_learner(learner_id, created_at)

dictation_lines ​

migration: 0190_dictation.sql

CộtKiểuRàng buộc / ghi chú
set_idTEXTNOT NULL REFERENCES dictation_sets(id) ON DELETE CASCADE
seqINTEGERNOT NULL
textTEXTNOT NULL
start_msINTEGERNOT NULL DEFAULT 0
end_msINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (set_id, seq)

Khóa ngoại: set_id → dictation_sets

dictation_practice ​

── Một bài luyện của một learner ─────────────────────────────────────────────────────────────── status có 'pending'/'running' vì sinh chữ rồi sinh tiếng mất 15-40 giây: learner phải thấy được "đang làm" chứ không phải một trang trống rồi đột nhiên có bài. best_percent là điểm CAO NHẤT từng đạt, không phải điểm lần cuối. Hạn mức mở khoá dựa vào nó (chỉ đạo: nghe xong 100% một audio thì được tạo tiếp), mà một lần bấm nhầm "Kiểm tra" lúc ô còn trống không được phép lấy mất cái đã đạt.

migration: 0195_dictation_practice.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL
seed_idTEXTREFERENCES dictation_seeds(id)
seed_label_viTEXTNOT NULL
seed_label_enTEXTNOT NULL
levelINTEGERNOT NULL CHECK (level BETWEEN 1 AND 5)
sentence_countINTEGERNOT NULL CHECK (sentence_count BETWEEN 3 AND 10)
voiceTEXTNOT NULL
title_enTEXTNOT NULL DEFAULT ''
statusTEXTNOT NULL DEFAULT 'pending'
—table constraintCHECK (status IN ('pending','running','ready','failed'))
errorTEXTNOT NULL DEFAULT ''
audio_mimeTEXTNOT NULL DEFAULT 'audio/wav'
duration_msINTEGERNOT NULL DEFAULT 0
modelTEXTNOT NULL DEFAULT ''
best_percentINTEGERNOT NULL DEFAULT 0
attemptsINTEGERNOT NULL DEFAULT 0
listen_msINTEGERNOT NULL DEFAULT 0
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ready_atTEXT—

Khóa ngoại: seed_id → dictation_seeds

Index: idx_dictation_practice_learner(learner_id, created_at DESC)

dictation_practice_lines ​

migration: 0195_dictation_practice.sql

CộtKiểuRàng buộc / ghi chú
practice_idTEXTNOT NULL REFERENCES dictation_practice(id) ON DELETE CASCADE
seqINTEGERNOT NULL
textTEXTNOT NULL
start_msINTEGERNOT NULL DEFAULT 0
end_msINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (practice_id, seq)

Khóa ngoại: practice_id → dictation_practice

dictation_seeds ​

── Kho hạt giống chủ đề ──────────────────────────────────────────────────────────────────────── group_slug là chuỗi TỰ DO, không phải enum: chủ dự án 2026-09-02 nói rõ "có thể có nhiều nhóm khác nữa, tôi liệt kê nhanh 4 nhóm đó thôi". Đóng cứng danh sách nhóm ở tầng schema thì thêm một nhóm là một lần deploy — mà thêm nhóm chính là việc người vận hành sẽ làm thường xuyên nhất.

migration: 0195_dictation_practice.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
group_slugTEXTNOT NULL
group_viTEXTNOT NULL
group_enTEXTNOT NULL
label_viTEXTNOT NULL
label_enTEXTNOT NULL
brief_enTEXTNOT NULL
enabledINTEGERNOT NULL DEFAULT 1
sortINTEGERNOT NULL DEFAULT 0
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: idx_dictation_seeds_group(group_slug, sort)

dictation_sets ​

Bài tập chép chính tả cho chặng Investigate của course IELTS Core (SRC-668). Learner chọn một trong ba đoạn nghe (mỗi đoạn <= 2 phút), nghe rồi gõ lại TỪNG CÂU vào một ô riêng, bấm "Kiểm tra" và nhận phần trăm trùng khớp so với câu gốc. Bấm vào đầu mỗi ô là nghe lại ĐÚNG câu đó — nên mỗi câu phải có mốc thời gian trong file audio. Vì sao câu gốc nằm ở bảng RIÊNG chứ không phải một ô JSON trong dictation_sets: mốc thời gian (start_ms, end_ms) là thứ API trả cho trình duyệt, còn text là thứ TUYỆT ĐỐI KHÔNG được trả trước lúc chấm — hai loại dữ liệu có quyền đọc khác nhau thì không ở chung một ô. status mặc định 'draft': nội dung do máy sinh ra, chủ dự án duyệt rồi mới chuyển 'published'. API học chỉ đọc 'published' (xem modules/dictation/service.ts), nên một đoạn chưa duyệt không có đường nào lọt tới learner.

migration: 0190_dictation.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
lesson_idTEXTNOT NULL REFERENCES curriculum_lessons(id) ON DELETE CASCADE
seqINTEGERNOT NULL
topicTEXTNOT NULL, -- slug chủ đề, ví dụ 'film-up' | 'culture-japan'
topic_label_viTEXTNOT NULL DEFAULT ''
title_viTEXTNOT NULL
title_enTEXTNOT NULL
audio_mimeTEXTNOT NULL DEFAULT 'audio/mpeg'
duration_msINTEGERNOT NULL DEFAULT 0
voiceTEXTNOT NULL DEFAULT '', -- tên giọng của model TTS, để sinh lại đúng giọng cũ
source_noteTEXTNOT NULL DEFAULT '', -- ngữ liệu lấy ý từ đâu, cho người duyệt truy lại
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','retired'))
created_atTEXTNOT NULL DEFAULT (datetime('now'))
updated_atTEXTNOT NULL DEFAULT (datetime('now'))
—table constraintUNIQUE (lesson_id, seq)
topic_label_enTEXTNOT NULL DEFAULT '' — thêm ở 0192_dictation_topic_label_en.sql

Khóa ngoại: lesson_id → curriculum_lessons

Index: idx_dictation_sets_lesson(lesson_id, seq)

email_dispatches ​

Sổ CHỐNG GỬI TRÙNG cho thư gửi phụ huynh (SRC-683). Vì sao không dùng lại email_sends của 0205: hai bảng, hai vai. email_sends là sổ QUAN SÁT — ghi cả thư hỏng, ghi cả thư gửi lại, và một lỗi ghi sổ ở đó không được phép chặn một lá thư. Bảng này là KHOÁ: giành được hàng mới được gửi. Trộn hai vai vào một bảng thì hoặc mất thư, hoặc gửi hai lần — cả hai đều không hoàn tác được. dedupe_key là câu trả lời cho "đã gửi cái này chưa" của TỪNG loại thư, do mã sinh ra: welcome-parent → user_id (một đời một lần) diagnostic-ready → assessment session id milestone → learner_id:node_id (mỗi kỹ năng vững đúng một lần) exam-countdown → exam_id:30 | :14 | :3 (ba mốc, mỗi mốc một thư) inactivity-nudge → learner_id:ngày VN (kèm trần 7 ngày kiểm bằng email_sends) weekly-report → learner_id📧tuần trial-reminder → session_id:learner_id setup-nudge → user_id event-confirmation → event_id:user_id

migration: 0206_email_dispatch.sql

CộtKiểuRàng buộc / ghi chú
template_idTEXTNOT NULL
dedupe_keyTEXTNOT NULL
to_emailTEXTNOT NULL
learner_idTEXTREFERENCES learners(id)
send_idTEXT—
statusTEXTNOT NULL DEFAULT 'claimed' CHECK (status IN ('claimed','sent','skipped','failed'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (template_id, dedupe_key)

Khóa ngoại: learner_id → learners

Index: idx_email_dispatches_created(created_at DESC)

email_opens ​

Mỗi lần ảnh theo dõi được tải là một dòng. Không khoá duy nhất theo send: mở lại là dữ liệu thật, không phải trùng lặp.

migration: 0205_email_tracking.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
send_idTEXTNOT NULL REFERENCES email_sends(id)
opened_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
user_agentTEXT—
fetch_kindTEXTNOT NULL DEFAULT 'direct' CHECK (fetch_kind IN ('direct','proxy'))

Khóa ngoại: send_id → email_sends

Index: idx_email_opens_send(send_id, opened_at DESC)

email_sends ​

Sổ thư đã gửi và sổ lượt mở (chỉ đạo chủ dự án 2026-09-07, SRC-682). Chỉ đạo: "Hệ thống cần tracking được ai mở email vào những thời điểm nào." HAI bảng chứ không một: một thư gửi đi có thể được mở NHIỀU lần, và câu hỏi của chủ dự án là "vào những thời điểm nào" — số nhiều. Nhét opened_at thành một cột trong bảng gửi thì lần mở thứ hai ghi đè lần thứ nhất và câu hỏi đó vĩnh viễn không trả lời được.

migration: 0205_email_tracking.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
template_idTEXTNOT NULL
to_emailTEXTNOT NULL
learner_idTEXTREFERENCES learners(id)
subjectTEXTNOT NULL
track_tokenTEXTNOT NULL UNIQUE
statusTEXTNOT NULL DEFAULT 'sent' CHECK (status IN ('sent','skipped','failed'))
provider_idTEXT—
errorTEXT—
sent_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

Index: idx_email_sends_to(to_email, sent_at DESC) · idx_email_sends_template(template_id, sent_at DESC) · idx_email_sends_learner(learner_id, sent_at DESC)

email_suppressions ​

Danh sách KHÔNG gửi thư (SRC-684). Chỉ đạo chủ dự án 2026-09-07: loại hai hồ sơ ra khỏi danh sách gửi. Cách rẻ nhất là sửa thẳng learners.status thành 'archived' — và đó là cách SAI: trạng thái learner điều khiển cả việc học, báo cáo, quyền truy cập. Tắt thư bằng cách xoá sổ một đứa trẻ khỏi hệ thống là dùng dao mổ trâu, và ba tháng nữa không ai còn nhớ vì sao hồ sơ đó bị archive. Bảng này nói đúng một điều: "đừng gửi thư cho đối tượng này". Nó cũng chính là chỗ mà nút "ngừng nhận thư" của phụ huynh sẽ ghi vào khi được xây — một hệ gửi thư hàng loạt mà không có đường tắt là hệ sẽ bị đánh dấu spam.

migration: 0207_email_suppressions.sql

CộtKiểuRàng buộc / ghi chú
scopeTEXTNOT NULL CHECK (scope IN ('learner','email'))
keyTEXTNOT NULL
reasonTEXTNOT NULL
created_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (scope, key)