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

# 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](../data-dictionary.md). 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `doc_key` | TEXT | PRIMARY KEY |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('program','course','unit','lesson')) |
| `course_id` | TEXT | — |
| `schema_version` | INTEGER | NOT NULL DEFAULT 1 |
| `draft_json` | TEXT | NOT NULL |
| `draft_rev` | INTEGER | NOT NULL DEFAULT 1 |
| `draft_updated_by` | TEXT | — |
| `draft_updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `live_version` | INTEGER | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `user_id` | TEXT | PRIMARY KEY REFERENCES users(id) |
| `granted_by` | TEXT | — |
| `granted_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `doc_key` | TEXT | PRIMARY KEY |
| `kind` | TEXT | NOT NULL |
| `course_id` | TEXT | — |
| `json` | TEXT | NOT NULL |
| `fingerprint` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `course_id` | TEXT | PRIMARY KEY |
| `release_id` | TEXT | NOT NULL |
| `updated_by` | TEXT | — |
| `updated_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `release_id` | TEXT | PRIMARY KEY |
| `course_id` | TEXT | NOT NULL |
| `manifest_json` | TEXT | NOT NULL |
| `created_by` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `note` | TEXT | — |
| `validator_version` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `doc_key` | TEXT | NOT NULL |
| `version` | INTEGER | NOT NULL |
| `json` | TEXT | NOT NULL |
| `fingerprint` | TEXT | NOT NULL |
| `created_by` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `note` | TEXT | — |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `course_id` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','cancelled')) |
| `enrolled_by_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `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')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `course_id` | TEXT | NOT NULL |
| `unit_no` | INTEGER | NOT NULL CHECK (unit_no BETWEEN 1 AND 20) |
| `lesson_no` | INTEGER | NOT NULL CHECK (lesson_no BETWEEN 1 AND 20) |
| `exercise_no` | INTEGER | NOT NULL CHECK (exercise_no BETWEEN 1 AND 20) |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('choice','practice')) |
| `choice_index` | INTEGER | — |
| `correct` | INTEGER | CHECK (correct IN (0,1)) |
| `checked` | TEXT | — |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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='&lt;course&gt;:u&lt;n&gt;'` 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `scope` | TEXT | NOT NULL CHECK (scope = 'program' OR scope GLOB 'ai-teen-\[0-9]*:u\[0-9]*') |
| `state_json` | TEXT | NOT NULL CHECK (json_valid(state_json) AND length(state_json) <= 32000) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `course_id` | TEXT | NOT NULL |
| `unit_no` | INTEGER | NOT NULL |
| `slot` | INTEGER | NOT NULL |
| `fingerprint` | TEXT | NOT NULL |
| `first_released_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `retired_at` | TEXT | — |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `course_id` | TEXT | NOT NULL |
| `unit_no` | INTEGER | NOT NULL CHECK (unit_no BETWEEN 1 AND 20) |
| `lesson_slot` | INTEGER | NOT NULL CHECK (lesson_slot BETWEEN 1 AND 20) |
| `activity_id` | TEXT | NOT NULL CHECK (length(activity_id) BETWEEN 2 AND 32) |
| `step_id` | TEXT | NOT NULL CHECK (length(step_id) BETWEEN 2 AND 32) |
| `ticked_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `ticked_by_user_id` | TEXT | NOT NULL |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `service` | TEXT | NOT NULL REFERENCES article_services(service) |
| `slug` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('jtbd', 'journey')) |
| `title` | TEXT | NOT NULL |
| `audience` | TEXT | NOT NULL |
| `description` | TEXT | NOT NULL |
| `intro` | TEXT | NOT NULL |
| `job` | TEXT | — |
| `sort_order` | INTEGER | NOT NULL |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `service` | TEXT | PRIMARY KEY CHECK (service IN ('ielts', 'sat', 'speaking', 'grammar')) |
| `brand` | TEXT | NOT NULL |
| `base_path` | TEXT | NOT NULL UNIQUE |
| `tagline` | TEXT | NOT NULL |
| `hub_title` | TEXT | NOT NULL |
| `hub_description` | TEXT | NOT NULL |
| `hub_intro` | TEXT | NOT NULL |
| `cta_json` | TEXT | NOT NULL |
| `links_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `updated_at` | TEXT | NOT NULL |

## `articles`

migration: `0313_articles.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `service` | TEXT | NOT NULL |
| `category` | TEXT | NOT NULL |
| `slug` | TEXT | NOT NULL |
| `title` | TEXT | NOT NULL |
| `description` | TEXT | NOT NULL |
| `keyword` | TEXT | NOT NULL |
| `angle` | TEXT | NOT NULL |
| `sort_order` | INTEGER | NOT NULL |
| `body_md` | TEXT | NOT NULL |
| `body_html` | TEXT | NOT NULL |
| `figures_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `word_count` | INTEGER | NOT NULL |
| `published_at` | TEXT | NOT NULL |
| `updated_at` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'published' CHECK (status IN ('draft', 'published')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `framework_code` | TEXT | NOT NULL REFERENCES b21_frameworks(code) |
| `code` | TEXT | NOT NULL |
| `name` | TEXT | NOT NULL |
| `name_vi` | TEXT | — |
| `slug` | TEXT | NOT NULL |
| `sort_order` | INTEGER | NOT NULL DEFAULT 0 |
| — | *table constraint* | `PRIMARY KEY (framework_code, code)` |

**Khóa ngoại:** `framework_code` → `b21_frameworks`

## `b21_competencies`

migration: `0174_b21_bilingual_frameworks.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `framework_code` | TEXT | NOT NULL |
| `code` | TEXT | NOT NULL |
| `area_code` | TEXT | NOT NULL |
| `name` | TEXT | NOT NULL |
| `name_vi` | TEXT | — |
| `slug` | TEXT | NOT NULL |
| `statement` | TEXT | — |
| `statement_vi` | TEXT | — |
| `standards` | TEXT | — |
| `sort_order` | INTEGER | NOT NULL DEFAULT 0 |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `code` | TEXT | PRIMARY KEY |
| `name` | TEXT | NOT NULL |
| `name_vi` | TEXT | — |
| `audience_vi` | TEXT | — |
| `slug` | TEXT | NOT NULL |
| `sort_order` | INTEGER | NOT NULL DEFAULT 0 |

## `b21_indicators`

migration: `0174_b21_bilingual_frameworks.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `level_id` | TEXT | NOT NULL REFERENCES b21_levels(id) |
| `ordinal` | INTEGER | NOT NULL |
| `statement` | TEXT | NOT NULL |
| `statement_vi` | TEXT | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `framework_code` | TEXT | NOT NULL |
| `skill_code` | TEXT | NOT NULL |
| `ordinal` | INTEGER | NOT NULL |
| `label` | TEXT | NOT NULL |
| `label_vi` | TEXT | — |
| — | *table constraint* | `UNIQUE (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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `framework_code` | TEXT | NOT NULL |
| `code` | TEXT | NOT NULL |
| `competency_code` | TEXT | NOT NULL |
| `name` | TEXT | NOT NULL |
| `name_vi` | TEXT | — |
| `slug` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL DEFAULT 'skill' |
| `guiding_question` | TEXT | — |
| `guiding_question_vi` | TEXT | — |
| `standards` | TEXT | — |
| `xq_crosswalk` | TEXT | — |
| `myways` | TEXT | — |
| `portrait` | TEXT | — |
| `sort_order` | INTEGER | NOT NULL DEFAULT 0 |
| — | *table constraint* | `PRIMARY 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ộ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) |
| `max_months_per_band` | INTEGER | CHECK (max_months_per_band IS NULL OR max_months_per_band > 0) |
| `terms_vi` | TEXT | — |
| `starts_at` | TEXT | — |
| `ends_at` | TEXT | — |
| `seq` | INTEGER | NOT NULL DEFAULT 1 |
| `active` | INTEGER | NOT NULL DEFAULT 1 |

**Index:** `idx_band_pricing_active`(active, kind, seq)

## `blueprint_weights`

\---------------------------------------------------------------- blueprint_weights

migration: `0082_english_state_enums.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `blueprint_id` | TEXT | NOT NULL REFERENCES blueprints(id) |
| `subject_id` | TEXT | NOT NULL |
| `node_id` | TEXT | NOT NULL |
| `weight` | REAL | NOT NULL |
| `required_state` | TEXT | NOT NULL CHECK (required_state IN |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `token` | TEXT | PRIMARY KEY |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `label` | TEXT | NOT NULL DEFAULT '',       -- "iPhone của mẹ" — để biết rút cái nào khi cần |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `last_used_at` | TEXT | ,                    -- app lịch gọi lần cuối lúc nào; rỗng lâu = người dùng bỏ |
| `revoked_at` | TEXT | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `code` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `course_id` | TEXT | NOT NULL |
| `earned_at` | 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_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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `images_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `caption` | TEXT | NOT NULL,            -- một dòng dưới ảnh trên lưới |
| `description` | TEXT | ,                 -- đoạn dài hơn, chỉ hiện trong lightbox |
| `learner_name` | TEXT | NOT NULL,       -- được phép viết tắt: "Minh A." |
| `learner_info` | TEXT | ,                -- "Lớp 11, THPT Chu Văn An · IELTS 5.5 → 7.0" |
| `said_on` | TEXT | ,                     -- YYYY-MM-DD, ngày của đoạn chat; trang hiện DD.MM.YYYY |
| `source` | TEXT | NOT NULL DEFAULT 'zalo', -- zalo | zoom | other; cổng ở zod, không CHECK |
| `tags_json` | TEXT | NOT NULL DEFAULT '\[]', -- \["ielts","speak"]; danh sách cố định trong API |
| `consent` | INTEGER | NOT NULL DEFAULT 0,   -- mentor cam kết đã xin phép học viên |
| `redacted` | INTEGER | NOT NULL DEFAULT 0,  -- mentor cam kết đã che tên/avatar/sđt người khác |
| `featured` | INTEGER | NOT NULL DEFAULT 0,  -- ghim lên đầu lưới |
| `status` | TEXT | NOT NULL DEFAULT 'draft', -- draft | published |
| `created_by` | TEXT | REFERENCES users(id) |
| `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:** `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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `event_id` | TEXT | NOT NULL |
| `user_id` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL CHECK (status IN ('joined', 'cancelled')) |
| `display_name` | TEXT | ,                    -- chụp lại tên lúc đăng ký để danh sách không phải join users |
| `note` | TEXT | ,                            -- ghi chú của người đăng ký, vd giờ có mặt nếu tới muộn |
| `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')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY,                  -- slug người đọc được, dùng thẳng trên URL (#/events/{id}) |
| `series` | TEXT | NOT NULL DEFAULT '',      -- tên chuỗi sự kiện, vd "Cafe cùng Nemo12" |
| `title` | TEXT | NOT NULL |
| `summary` | TEXT | NOT NULL DEFAULT '',     -- một câu cho thẻ danh sách |
| `goal` | TEXT | NOT NULL DEFAULT '',        -- khối "Mục tiêu" ở trang chi tiết, xuống dòng = một đoạn |
| `audience` | TEXT | ,                        -- "Buổi này dành cho ai" |
| `agenda_json` | TEXT | ,                     -- \[{"t":"15:00","label":"…"}] — lịch trình trong buổi |
| `notes` | TEXT | ,                           -- lưu ý trước khi đến, mỗi dòng một gạch đầu dòng |
| `starts_at` | TEXT | NOT NULL,              -- ISO 8601 CÓ offset (…+07:00) — client tự đổi sang giờ máy |
| `duration_min` | INTEGER | NOT NULL DEFAULT 90 |
| `mode` | TEXT | NOT NULL DEFAULT 'offline' CHECK (mode IN ('offline', 'online')) |
| `venue` | TEXT | — |
| `address` | TEXT | — |
| `capacity` | INTEGER | ,                     -- NULL = không giới hạn chỗ |
| `fee` | TEXT | — |
| `fee_note` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| `host_user_id` | TEXT |  — thêm ở 0215_event_interactions.sql |
| `youtube_video_id` | TEXT | CHECK (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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `stage` | TEXT | NOT NULL |
| `target_id` | TEXT | NOT NULL |
| `outcome` | TEXT | NOT NULL CHECK (outcome IN ('rejected','failed')) |
| `reason` | TEXT | NOT NULL |
| `sample` | TEXT | — |
| `at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (stage, target_id)` |
