---
url: https://docs.nemo12.com/reference/data-dictionary/other-3.md
description: >-
  Từ điển dữ liệu D1, domain Khác (phần 3/11): 34 bảng,
  curriculum_lesson_interventions tới email_suppressions; cột, kiểu, khoá ngoại,
  index.
---

# 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](../data-dictionary.md). 34 bảng, `curriculum_lesson_interventions` tới `email_suppressions`.

## `curriculum_lesson_interventions`

migration: `0086_course_specs_cp_english.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `seq` | INTEGER | NOT NULL |
| `evidence_vi` | TEXT | NOT NULL |
| `action_vi` | TEXT | NOT NULL |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `seq` | INTEGER | NOT NULL |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('youtube','pdf','lab','link')) |
| `title_vi` | TEXT | NOT NULL |
| `url` | TEXT | — |
| `ref_id` | TEXT | — |
| `minutes` | INTEGER | — |
| `reflection_prompt_vi` | TEXT | — |
| — | *table constraint* | `PRIMARY KEY (lesson_id, seq)` |
| — | *table constraint* | `CHECK ((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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| `unit_code` | TEXT | — |
| — | *table constraint* | `PRIMARY KEY (lesson_id, seq)` |
| `text_en` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| — | *table constraint* | `PRIMARY KEY (lesson_id, seq)` |
| `text_en` | TEXT |  — thêm ở 0191_bilingual_outcome_gaps.sql |

**Khóa ngoại:** `lesson_id` → `curriculum_lessons`

## `curriculum_lesson_questions`

migration: `0181_curriculum_bilingual.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | — |
| `text_en` | TEXT | — |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `seq` | INTEGER | NOT NULL |
| `stem_vi` | TEXT | NOT NULL |
| `stem_en` | TEXT | — |
| `answer` | INTEGER | NOT NULL CHECK (answer BETWEEN 0 AND 3) |
| `why_vi` | TEXT | — |
| `why_en` | TEXT | — |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL |
| `quiz_seq` | INTEGER | NOT NULL |
| `seq` | INTEGER | NOT NULL CHECK (seq BETWEEN 0 AND 3) |
| `text_vi` | TEXT | NOT NULL |
| `text_en` | TEXT | — |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `learner_id` | TEXT | NOT NULL |
| `findings_vi` | TEXT | NOT NULL |
| `future_vi` | TEXT | NOT NULL |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | PRIMARY KEY REFERENCES curriculum_lessons(id) |
| `situation_vi` | TEXT | NOT NULL |
| `complication_vi` | TEXT | NOT NULL |
| `question_vi` | TEXT | NOT NULL |
| `resolution_vi` | TEXT | NOT NULL |
| `weak` | INTEGER | NOT NULL DEFAULT 0 — thêm ở 0184_story_weak_flag.sql |
| `situation_en` | TEXT |  — thêm ở 0194_lesson_content_bilingual.sql |
| `complication_en` | TEXT |  — thêm ở 0194_lesson_content_bilingual.sql |
| `question_en` | TEXT |  — thêm ở 0194_lesson_content_bilingual.sql |
| `resolution_en` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| `text_en` | TEXT | — |
| — | *table constraint* | `PRIMARY KEY (lesson_id, seq)` |

**Khóa ngoại:** `lesson_id` → `curriculum_lessons`

## `curriculum_lessons`

migration: `0084_curriculum_courses.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `seq` | INTEGER | NOT NULL |
| `name_vi` | TEXT | NOT NULL |
| `guiding_question_vi` | TEXT | — |
| `concepts_vi` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `name_en` | TEXT |  — thêm ở 0181_curriculum_bilingual.sql |
| `guiding_question_en` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL |
| `part` | TEXT | NOT NULL |
| `completed_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) |
| `learner_id` | TEXT | NOT NULL |
| `stage` | TEXT | NOT NULL |
| `completed_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_unit_tasks(unit_id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| `outcome_seq` | INTEGER | — |
| — | *table constraint* | `PRIMARY KEY (unit_id, seq)` |
| `text_en` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('key_concept', 'skill')) |
| `seq` | INTEGER | NOT NULL |
| `label_vi` | TEXT | NOT NULL |
| `label_en` | TEXT | — |
| `detail_vi` | TEXT | — |
| `detail_en` | TEXT | — |
| — | *table constraint* | `PRIMARY KEY (unit_id, kind, seq)` |
| `example_vi` | TEXT |  — thêm ở 0203_curriculum_audience.sql |
| `example_en` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('key_concept','material','framework','model','formula','principle')) |
| `seq` | INTEGER | NOT NULL |
| `label_vi` | TEXT | NOT NULL |
| `detail_vi` | TEXT | — |
| — | *table constraint* | `PRIMARY KEY (unit_id, kind, seq)` |

**Khóa ngoại:** `unit_id` → `curriculum_units`

## `curriculum_unit_misconceptions`

migration: `0086_course_specs_cp_english.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `code` | TEXT | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| — | *table constraint* | `PRIMARY KEY (unit_id, seq)` |
| `text_en` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | — |
| `text_en` | TEXT | — |
| — | *table constraint* | `PRIMARY KEY (unit_id, seq)` |

**Khóa ngoại:** `unit_id` → `curriculum_units`

## `curriculum_unit_specs`

migration: `0086_course_specs_cp_english.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | PRIMARY KEY REFERENCES curriculum_units(id) |
| `purpose_vi` | TEXT | — |
| `progression_vi` | TEXT | — |
| `mastery_criteria_vi` | TEXT | — |

**Khóa ngoại:** `unit_id` → `curriculum_units`

## `curriculum_unit_takeaways`

migration: `0185_key_takeaways.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | NOT NULL REFERENCES curriculum_units(id) |
| `seq` | INTEGER | NOT NULL |
| `text_vi` | TEXT | NOT NULL |
| `text_en` | TEXT | — |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `unit_id` | TEXT | PRIMARY KEY REFERENCES curriculum_units(id) |
| `title_vi` | TEXT | NOT NULL |
| `prompt_vi` | TEXT | NOT NULL |
| `product_vi` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `title_en` | TEXT |  — thêm ở 0181_curriculum_bilingual.sql |
| `prompt_en` | TEXT |  — thêm ở 0181_curriculum_bilingual.sql |
| `product_en` | TEXT |  — thêm ở 0181_curriculum_bilingual.sql |

**Khóa ngoại:** `unit_id` → `curriculum_units`

## `curriculum_units`

migration: `0084_curriculum_courses.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `course_id` | TEXT | NOT NULL REFERENCES curriculum_courses(id) |
| `seq` | INTEGER | NOT NULL |
| `name_vi` | TEXT | NOT NULL |
| `big_idea_vi` | TEXT | — |
| `essential_question_vi` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `name_en` | TEXT |  — thêm ở 0181_curriculum_bilingual.sql |
| `big_idea_en` | TEXT |  — thêm ở 0181_curriculum_bilingual.sql |
| `essential_question_en` | TEXT |  — thêm ở 0181_curriculum_bilingual.sql |
| `compelling_question_vi` | TEXT |  — thêm ở 0332_curriculum_units_compelling_question.sql |
| `compelling_question_en` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `set_id` | TEXT | NOT NULL REFERENCES dictation_sets(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL |
| `scores_json` | TEXT | NOT NULL,               -- \[{seq, percent}] — KHÔNG lưu chữ learner gõ |
| `overall` | INTEGER | NOT NULL |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `set_id` | TEXT | NOT NULL REFERENCES dictation_sets(id) ON DELETE CASCADE |
| `seq` | INTEGER | NOT NULL |
| `text` | TEXT | NOT NULL |
| `start_ms` | INTEGER | NOT NULL DEFAULT 0 |
| `end_ms` | INTEGER | NOT NULL DEFAULT 0 |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL |
| `seed_id` | TEXT | REFERENCES dictation_seeds(id) |
| `seed_label_vi` | TEXT | NOT NULL |
| `seed_label_en` | TEXT | NOT NULL |
| `level` | INTEGER | NOT NULL CHECK (level BETWEEN 1 AND 5) |
| `sentence_count` | INTEGER | NOT NULL CHECK (sentence_count BETWEEN 3 AND 10) |
| `voice` | TEXT | NOT NULL |
| `title_en` | TEXT | NOT NULL DEFAULT '' |
| `status` | TEXT | NOT NULL DEFAULT 'pending' |
| — | *table constraint* | `CHECK (status IN ('pending','running','ready','failed'))` |
| `error` | TEXT | NOT NULL DEFAULT '' |
| `audio_mime` | TEXT | NOT NULL DEFAULT 'audio/wav' |
| `duration_ms` | INTEGER | NOT NULL DEFAULT 0 |
| `model` | TEXT | NOT NULL DEFAULT '' |
| `best_percent` | INTEGER | NOT NULL DEFAULT 0 |
| `attempts` | INTEGER | NOT NULL DEFAULT 0 |
| `listen_ms` | INTEGER | NOT NULL DEFAULT 0 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `ready_at` | TEXT | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `practice_id` | TEXT | NOT NULL REFERENCES dictation_practice(id) ON DELETE CASCADE |
| `seq` | INTEGER | NOT NULL |
| `text` | TEXT | NOT NULL |
| `start_ms` | INTEGER | NOT NULL DEFAULT 0 |
| `end_ms` | INTEGER | NOT NULL DEFAULT 0 |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `group_slug` | TEXT | NOT NULL |
| `group_vi` | TEXT | NOT NULL |
| `group_en` | TEXT | NOT NULL |
| `label_vi` | TEXT | NOT NULL |
| `label_en` | TEXT | NOT NULL |
| `brief_en` | TEXT | NOT NULL |
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
| `sort` | INTEGER | NOT NULL DEFAULT 0 |
| `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:** `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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `lesson_id` | TEXT | NOT NULL REFERENCES curriculum_lessons(id) ON DELETE CASCADE |
| `seq` | INTEGER | NOT NULL |
| `topic` | TEXT | NOT NULL,              -- slug chủ đề, ví dụ 'film-up' | 'culture-japan' |
| `topic_label_vi` | TEXT | NOT NULL DEFAULT '' |
| `title_vi` | TEXT | NOT NULL |
| `title_en` | TEXT | NOT NULL |
| `audio_mime` | TEXT | NOT NULL DEFAULT 'audio/mpeg' |
| `duration_ms` | INTEGER | NOT NULL DEFAULT 0 |
| `voice` | TEXT | NOT NULL DEFAULT '',   -- tên giọng của model TTS, để sinh lại đúng giọng cũ |
| `source_note` | TEXT | NOT NULL DEFAULT '',   -- ngữ liệu lấy ý từ đâu, cho người duyệt truy lại |
| `status` | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','retired')) |
| `created_at` | TEXT | NOT NULL DEFAULT (datetime('now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (datetime('now')) |
| — | *table constraint* | `UNIQUE (lesson_id, seq)` |
| `topic_label_en` | TEXT | NOT 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:email:tuần   trial-reminder      → session_id:learner_id   setup-nudge         → user_id   event-confirmation  → event_id:user_id

migration: `0206_email_dispatch.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `template_id` | TEXT | NOT NULL |
| `dedupe_key` | TEXT | NOT NULL |
| `to_email` | TEXT | NOT NULL |
| `learner_id` | TEXT | REFERENCES learners(id) |
| `send_id` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'claimed' CHECK (status IN ('claimed','sent','skipped','failed')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `send_id` | TEXT | NOT NULL REFERENCES email_sends(id) |
| `opened_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `user_agent` | TEXT | — |
| `fetch_kind` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `template_id` | TEXT | NOT NULL |
| `to_email` | TEXT | NOT NULL |
| `learner_id` | TEXT | REFERENCES learners(id) |
| `subject` | TEXT | NOT NULL |
| `track_token` | TEXT | NOT NULL UNIQUE |
| `status` | TEXT | NOT NULL DEFAULT 'sent' CHECK (status IN ('sent','skipped','failed')) |
| `provider_id` | TEXT | — |
| `error` | TEXT | — |
| `sent_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `scope` | TEXT | NOT NULL CHECK (scope IN ('learner','email')) |
| `key` | TEXT | NOT NULL |
| `reason` | TEXT | NOT NULL |
| `created_by` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (scope, key)` |
