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

# Data Dictionary · Khác (phần 4/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). 29 bảng, `essay_comments` tới `grammar_live_recordings`.

## `essay_comments`

Nhận xét inline của mentor. Neo vào một đoạn chữ (anchor_text + vị trí) của một phiên bản.

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `draft_id` | TEXT | NOT NULL REFERENCES essay_drafts(id) |
| `version_id` | TEXT | REFERENCES essay_draft_versions(id) |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `author_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('question', 'observation', 'rubric')) |
| `skill` | TEXT | — |
| `anchor_start` | INTEGER | NOT NULL DEFAULT 0 |
| `anchor_end` | INTEGER | NOT NULL DEFAULT 0 |
| `anchor_text` | TEXT | NOT NULL DEFAULT '' |
| `body` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `draft_id` → `essay_drafts` · `version_id` → `essay_draft_versions` · `learner_id` → `learners` · `author_user_id` → `users`

**Index:** `idx_essay_comments_draft`(draft_id, created_at)

## `essay_course_orders`

Migration 0317 - essay program. SRC-1153, SDD-051, PRD-009.  ## Vì sao migration này tồn tại  Chủ dự án 30.09.2026: NEMO ESSAY ở learn.nemo12.com/essay, một khoá 12 buổi 8.000.000đ, đăng ký như NEMO SPEAK (đơn, chuyển khoản, admin bấm "Đã nhận tiền", mã kích hoạt ESSAY-XXXXXX). Sau khi ghi danh, learner có bộ công cụ viết: Story Bank, Reflection Ladder, bản nháp có lịch sử phiên bản, nhận xét của mentor, AI chỉ đặt câu hỏi, bài luyện "Show, don't tell". Không có các bảng dưới đây thì không chỗ nào giữ đơn, ghi danh, hay bất kỳ chữ nào learner viết.  VÌ SAO BẢNG ĐƠN RIÊNG thay vì nới `speak_course_orders` (0312): cột course_id ở đó có CHECK ('speak-2','speak-3'), và D1/SQLite không sửa được CHECK nếu không dựng lại bảng, tức là chép lại một bảng đang giữ tiền thật của khách. Bảng ghi danh speak còn mang `first_session_index` của lịch cuốn chiếu, thứ essay không có. Hai bảng song song cùng hình dạng rẻ và an toàn hơn; tab admin đọc cả hai qua hai endpoint (SDD-051 §4).  RIÊNG TƯ: ghi chú, highlight, câu chuyện, bậc thang và bản nháp chỉ chủ tài khoản đọc/ghi. Mentor được giao (mentor_assignments active) đọc bản nháp và CHỈ thêm nhận xét; không có câu UPDATE nào của mentor chạm vào essay_drafts / essay_draft_versions (SDD-051 §6).  Nhật ký quá trình dựng từ các lượt LƯU (snapshot), không có bảng phím gõ nào: đó là một quyết định, không phải một thiếu sót (PRD-009 §4).  ## Chạy lại không đổi gì  Toàn bộ là `CREATE TABLE IF NOT EXISTS` và `CREATE INDEX IF NOT EXISTS`.

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `amount_vnd` | INTEGER | NOT NULL CHECK (amount_vnd >= 0) |
| `transfer_ref` | TEXT | NOT NULL UNIQUE |
| `status` | TEXT | NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'cancelled')) |
| `activation_code` | TEXT | UNIQUE |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `paid_at` | TEXT | — |
| `paid_by` | TEXT | — |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_essay_course_orders_learner`(learner_id) · `idx_essay_course_orders_status`(status, created_at)

## `essay_draft_versions`

Snapshot theo PHIÊN LƯU: các lượt lưu cùng session_key trong 30 phút gộp vào một phiên bản.

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `draft_id` | TEXT | NOT NULL REFERENCES essay_drafts(id) |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `version_no` | INTEGER | NOT NULL CHECK (version_no >= 1) |
| `session_key` | TEXT | NOT NULL |
| `body` | TEXT | NOT NULL |
| `word_count` | INTEGER | NOT NULL |
| `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* | `UNIQUE (draft_id, version_no)` |

**Khóa ngoại:** `draft_id` → `essay_drafts` · `learner_id` → `learners`

## `essay_drafts`

Một bản nháp mỗi đề của mỗi learner; chữ hiện tại nằm ở đây, lịch sử ở essay_draft_versions.

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `prompt_id` | TEXT | NOT NULL |
| `body` | TEXT | NOT NULL DEFAULT '' |
| `word_count` | 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')) |
| — | *table constraint* | `UNIQUE (learner_id, prompt_id)` |

**Khóa ngoại:** `learner_id` → `learners`

## `essay_drill_attempts`

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `drill_id` | TEXT | NOT NULL |
| `answer` | 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_essay_drill_attempts_learner`(learner_id, drill_id)

## `essay_enrollments`

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | PRIMARY KEY REFERENCES learners(id) |
| `activation_code` | TEXT | NOT NULL UNIQUE |
| `activated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `learner_id` → `learners`

## `essay_ladder_rungs`

Sáu bậc của Reflection Ladder cho một câu chuyện. Bậc n chỉ ghi được khi bậc n-1 đủ dài (API chặn).

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `story_id` | TEXT | NOT NULL REFERENCES essay_stories(id) |
| `rung` | INTEGER | NOT NULL CHECK (rung BETWEEN 1 AND 6) |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `answer` | TEXT | NOT NULL |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (story_id, rung)` |

**Khóa ngoại:** `story_id` → `essay_stories` · `learner_id` → `learners`

## `essay_marks`

Highlight + ghi chú riêng của learner trên một bài mẫu. from_idx/to_idx là chỉ số câu.

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `essay_id` | TEXT | NOT NULL |
| `from_idx` | INTEGER | NOT NULL CHECK (from_idx >= 0) |
| `to_idx` | INTEGER | NOT NULL CHECK (to_idx >= from_idx) |
| `note` | TEXT | NOT NULL DEFAULT '' |
| `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:** `learner_id` → `learners`

**Index:** `idx_essay_marks_learner`(learner_id, essay_id)

## `essay_process_log`

Dòng thời gian quá trình: draft_snapshot, ladder_answer, comment, ai_turn, drill, story.

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `kind` | TEXT | NOT NULL |
| `ref_id` | TEXT | — |
| `detail` | TEXT | NOT NULL DEFAULT '{}' |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `learner_id` → `learners`

**Index:** `idx_essay_process_log_learner`(learner_id, created_at)

## `essay_stories`

migration: `0317_essay_program.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `prompt_id` | TEXT | NOT NULL |
| `title` | TEXT | NOT NULL |
| `body` | TEXT | NOT NULL |
| `themes` | TEXT | NOT NULL DEFAULT '\[]' |
| `skills` | TEXT | NOT NULL DEFAULT '\[]' |
| `essay_types` | TEXT | NOT NULL DEFAULT '\[]' |
| `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:** `learner_id` → `learners`

**Index:** `idx_essay_stories_learner`(learner_id, updated_at)

## `event_activities`

migration: `0215_event_interactions.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `event_id` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('question', 'poll', 'reflection_4f', 'reflection_ssc')) |
| `title` | TEXT | NOT NULL |
| `prompt` | TEXT | NOT NULL DEFAULT '',        -- mô tả thêm, hiện dưới tiêu đề |
| `options_json` | TEXT | ,                      -- poll: \["Phương án 1", …]; question: NULL |
| `max_answers_per_person` | INTEGER | — |
| `allow_upvote` | INTEGER | NOT NULL DEFAULT 1 CHECK (allow_upvote IN (0, 1)) |
| `status` | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'open', 'closed')) |
| `position` | INTEGER | NOT NULL DEFAULT 0 |
| `is_ask_host` | INTEGER | NOT NULL DEFAULT 0 CHECK (is_ask_host IN (0, 1)) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |

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

**Index:** `idx_event_activities_event`(event_id, position)

## `event_activity_answers`

Câu trả lời free text. Xoá hẳn khi người viết rút lại: khác với đăng ký chỗ ngồi, ở đây không có nhu cầu biết "ai từng viết rồi xoá", và giữ lại thì bảng đếm vote phải lọc thêm một lần nữa.

migration: `0215_event_interactions.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `activity_id` | TEXT | NOT NULL |
| `event_id` | TEXT | NOT NULL |
| `user_id` | TEXT | NOT NULL |
| `display_name` | TEXT | ,                      -- chụp tên lúc gửi, để Live Stage không phải join users |
| `slot` | TEXT | — |
| `body` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |

**Khóa ngoại:** `activity_id` → `event_activities.id`

**Index:** `idx_event_answers_activity`(activity_id, created_at) · `idx_event_answers_author`(activity_id, user_id)

## `event_activity_participation`

Ai đã BẮT ĐẦU một hoạt động. Tách khỏi bảng câu trả lời vì đây là hai câu hỏi khác nhau: "đã mở ra làm chưa" và "đã viết gì chưa". Live Stage cần đúng khoảng cách giữa hai con số đó để host biết phòng đang bí ở chỗ nào (chỉ đạo chủ dự án 2026-09-13).

migration: `0215_event_interactions.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `activity_id` | TEXT | NOT NULL |
| `event_id` | TEXT | NOT NULL |
| `user_id` | TEXT | NOT NULL |
| `display_name` | TEXT | — |
| `started_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| — | *table constraint* | `PRIMARY KEY (activity_id, user_id)` |

**Khóa ngoại:** `activity_id` → `event_activities.id`

**Index:** `idx_event_participation_event`(event_id)

## `event_answer_upvotes`

Một người MỘT phiếu cho mỗi câu trả lời — khoá chính ép luật đó ở tầng dữ liệu, không phải ở tầng code. Bỏ upvote là xoá dòng, nên đếm phiếu luôn là COUNT(\*) thẳng, không có cột trạng thái nào phải nhớ lọc.

migration: `0215_event_interactions.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `answer_id` | TEXT | NOT NULL |
| `user_id` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| — | *table constraint* | `PRIMARY KEY (answer_id, user_id)` |

**Khóa ngoại:** `answer_id` → `event_activity_answers.id`

**Index:** `idx_event_upvotes_answer`(answer_id)

## `event_banners`

Migration 0343 - event banners. SRC-1312, SDD-018 §6e.  ## Vì sao migration này tồn tại  Mỗi buổi gặp cần cả một loạt banner: trước buổi (thông báo, đếm ngược, nhắc), trong buổi, sau buổi (cảm ơn, tổng kết, tài liệu...). Chủ dự án 10.10.2026: chọn một trong 30 mẫu, đổi sub-brand (NEMO SPEAK, NEMO IELTS...), đổi ba khổ (vuông, dọc 4:5, ngang), xem từng banner rồi bấm vào để sửa. Banner phải lưu ở server vì nhiều mentor cùng làm một buổi. Một dòng = một banner: mẫu nào, khổ nào, sub-brand nào, và các ô chữ đã sửa (`fields_json`). Ảnh KHÔNG lưu: dựng lại từ mẫu mỗi lần xem, nên đổi giờ buổi là mọi banner đổi theo. Không có xoá (luật Dolphin); `archived_at` để ẩn.  ## Chạy lại không đổi gì  CREATE TABLE / INDEX IF NOT EXISTS.

migration: `0343_event_banners.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `event_id` | TEXT | NOT NULL REFERENCES community_events(id) ON DELETE CASCADE |
| `template_id` | TEXT | NOT NULL |
| `phase` | TEXT | NOT NULL CHECK (phase IN ('before','during','after')) |
| `size` | TEXT | NOT NULL DEFAULT 'square' CHECK (size IN ('square','portrait','landscape')) |
| `brand` | TEXT | — |
| `fields_json` | TEXT | NOT NULL DEFAULT '{}' |
| `sort` | INTEGER | NOT NULL DEFAULT 100 |
| `created_by` | TEXT | — |
| `updated_by` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `archived_at` | TEXT | — |

**Khóa ngoại:** `event_id` → `community_events`

**Index:** `idx_event_banners_event`(event_id, phase, sort)

## `event_checklist_marks`

migration: `0342_event_checklist.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `event_id` | TEXT | NOT NULL REFERENCES community_events(id) ON DELETE CASCADE |
| `task_id` | TEXT | NOT NULL REFERENCES event_checklist_tasks(id) |
| `done` | INTEGER | NOT NULL DEFAULT 0 |
| `note` | TEXT | — |
| `updated_by` | TEXT | — |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (event_id, task_id)` |

**Khóa ngoại:** `event_id` → `community_events` · `task_id` → `event_checklist_tasks`

## `event_checklist_tasks`

Migration 0342 - event checklist. SRC-1312, SDD-018 §6d.  ## Vì sao migration này tồn tại  Mỗi buổi gặp có một checklist chia ba chặng (trước, trong, sau) và ba cụm việc (truyền thông tới người tham gia, khách mời và chuyên môn, logistics). Mentor tick/bỏ tick và ghi chú từng việc (chỉ đạo chủ dự án 10.10.2026). Một việc thêm vào là thêm cho MỌI buổi, để lần sau không phải tạo lại: vì thế danh sách việc là một bảng chung `event_checklist_tasks`, còn trạng thái tick và ghi chú của từng buổi nằm ở `event_checklist_marks`. Buổi chưa có dòng mark nào nghĩa là chưa làm. Không có xoá việc (luật Dolphin: không nút Delete); `archived_at` để ẩn một việc không dùng nữa mà không làm mất ghi chú cũ.  ## Chạy lại không đổi gì  CREATE TABLE IF NOT EXISTS và INSERT OR IGNORE theo id cố định của các việc mặc định.

migration: `0342_event_checklist.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `phase` | TEXT | NOT NULL CHECK (phase IN ('before','during','after')) |
| `area` | TEXT | NOT NULL CHECK (area IN ('comms','guests','logistics')) |
| `label` | TEXT | NOT NULL |
| `sort` | INTEGER | NOT NULL DEFAULT 100 |
| `created_by` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `archived_at` | TEXT | — |

## `event_poll_votes`

Poll: một người một phiếu cho mỗi hoạt động, đổi ý thì UPDATE chứ không sinh dòng thứ hai.

migration: `0215_event_interactions.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `activity_id` | TEXT | NOT NULL |
| `user_id` | TEXT | NOT NULL |
| `option_index` | INTEGER | NOT NULL |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| — | *table constraint* | `PRIMARY KEY (activity_id, user_id)` |

**Khóa ngoại:** `activity_id` → `event_activities.id`

**Index:** `idx_event_poll_votes_activity`(activity_id, option_index)

## `event_resources`

Tài nguyên của buổi gặp — SRC-711 (chỉ đạo chủ dự án 2026-09-15).  "Có trang riêng là Resources, cho từng event, để vào đó lấy các tài nguyên. Ngoài việc xem lại được recording nhúng trên web, thì còn có thể download vài file PDF, các ảnh nữa."  Video KHÔNG nằm ở bảng này: nó đã có `event_videos` (migration 0220), một buổi một video, và nó được XEM tại chỗ chứ không tải về. Bảng dưới đây là những thứ tải về được.  Byte của file nằm ở R2 (binding CONTENT), bảng này chỉ giữ siêu dữ liệu và khoá R2. Không nhét file vào D1: D1 là cơ sở dữ liệu quan hệ cho dữ liệu nhỏ đọc nhiều, còn một tài liệu PDF vài MB đi qua đó sẽ làm chậm mọi truy vấn khác dùng chung kết nối.

migration: `0222_event_resources.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `event_id` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('pdf', 'image', 'link')) |
| `title` | TEXT | NOT NULL |
| `url` | TEXT | — |
| `mime_type` | TEXT | — |
| `byte_size` | INTEGER | — |
| `position` | INTEGER | NOT NULL DEFAULT 0 |
| `uploaded_by` | TEXT | REFERENCES users(id) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| — | *table constraint* | `CHECK ((kind = 'link' AND url IS NOT NULL AND r2_key IS NULL)` |
| — | *table constraint* | `CHECK (length(title) BETWEEN 1 AND 200)` |

**Khóa ngoại:** `uploaded_by` → `users` · `event_id` → `community_events.id`

**Index:** `idx_event_resources_event`(event_id, position)

## `event_videos`

Video của buổi gặp, lần hai — SRC-711 (2026-09-15), thay đường ghi của SRC-720.  VÌ SAO CÓ FILE NÀY. Migration 0219 thêm cột `community_events.youtube_video_id` kèm một ràng buộc CHECK dùng mẫu GLOB gồm 11 lớp ký tự liên tiếp. D1 TỪ CHỐI đánh giá mẫu đó:      ✘ \[ERROR] LIKE or GLOB pattern too complex: SQLITE_ERROR  Ghi NULL thì qua (SQLite ngắt mạch ở vế `IS NULL`), ghi một ID hợp lệ thì lỗi — nên trên production MỌI lượt gắn video đều trả 500, còn test chạy trên SQLite của Node thì xanh vì bản ấy chấp nhận mẫu dài hơn. Đây là kiểu lỗi chỉ lộ ra ở đúng nơi nó gây hại.  VÌ SAO KHÔNG SỬA CỘT CŨ. SQLite không sửa được một CHECK, và không xoá được cột đang bị một CHECK nhắc tới. Đường duy nhất là dựng lại `community_events` — mà DROP bảng cha sẽ kích hoạt ON DELETE CASCADE, cuốn theo đăng ký, hoạt động, câu trả lời và phiếu bầu của người thật. Đổi lấy một cái iframe thì không đáng. Cột `youtube_video_id` và `video_caption` của 0219 từ nay BỎ KHÔNG DÙNG (luôn NULL); mọi chỗ đọc và ghi chuyển sang bảng dưới đây.  Một buổi một video, nên `event_id` vừa là khoá chính vừa là khoá ngoại: không có đường nào để hai dòng cùng nói về một buổi.

migration: `0220_event_video_table.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `event_id` | TEXT | PRIMARY KEY |
| `youtube_video_id` | TEXT | NOT NULL |
| `caption` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| — | *table constraint* | `CHECK (length(youtube_video_id) = 11 AND youtube_video_id NOT GLOB '*[^A-Za-z0-9_-]*')` |
| — | *table constraint* | `CHECK (caption IS NULL OR length(caption) <= 200)` |

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

## `experience_reviews`

migration: `0063_exp_reviews_unit_scope.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `target_type` | TEXT | NOT NULL CHECK (target_type IN ('lab','learning','assessment','item','unit')) |
| `target_id` | TEXT | NOT NULL |
| `subject_id` | TEXT | — |
| `node_id` | TEXT | — |
| `step_ref` | TEXT | — |
| `verdict` | TEXT | NOT NULL CHECK (verdict IN ('ok','minor','major','blocker')) |
| `tags_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `note` | TEXT | — |
| `reviewer_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `reviewer_user_id` → `users`

**Index:** `idx_exp_reviews_target`(target_type, target_id, created_at) · `idx_exp_reviews_verdict`(verdict, created_at)

## `family_contacts`

Người trong nhà CHƯA CÓ TÀI KHOẢN — Q-142: không sinh hàng users nào. users.email đang là UNIQUE NOT NULL, mà phần lớn bố mẹ mentor gặp ngoài đời chưa cho email; tạo users cho họ nghĩa là phải bịa email, tức là bịa khoá chính.  MỌI cột mô tả đều cho phép NULL, kể cả full_name — chỉ đạo nói thẳng "có thể thiếu thông tin này thông tin kia thì vẫn tạo được". Ràng buộc duy nhất nằm ở tầng API: một hàng phải có ít nhất một trong {full_name, phone, email} thì mới có gì để nhận ra người đó về sau.  role cố ý KHÔNG có 'child': con nằm ở bảng learners (Q-149). Hai chỗ chứa trẻ em là hai chỗ để quên đồng bộ.

migration: `0078_family_workspace.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `family_id` | TEXT | NOT NULL REFERENCES families(id) |
| `role` | TEXT | NOT NULL DEFAULT 'guardian' CHECK (role IN ('father','mother','guardian','other')) |
| `full_name` | TEXT | — |
| `phone` | TEXT | — |
| `zalo` | TEXT | — |
| `email` | TEXT | — |
| `occupation` | TEXT | — |
| `workplace` | TEXT | — |
| `address` | TEXT | — |
| `district` | TEXT | — |
| `province` | TEXT | — |
| `note` | TEXT | — |
| `user_id` | TEXT | REFERENCES users(id) |
| `linked_at` | TEXT | — |
| `linked_by` | TEXT | REFERENCES users(id) |
| `created_by` | TEXT | NOT NULL REFERENCES users(id) |
| `source` | TEXT | NOT NULL DEFAULT 'mentor' CHECK (source IN ('mentor','self')) |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')) |
| `display_order` | INTEGER | NOT NULL DEFAULT 100 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `photo_media_id` | TEXT |  — thêm ở 0083_family_member_photos_nickname.sql |
| `facebook_url` | TEXT |  — thêm ở 0161_person_profile_fields.sql |
| `links_json` | TEXT |  — thêm ở 0161_person_profile_fields.sql |
| `interests` | TEXT |  — thêm ở 0161_person_profile_fields.sql |
| `expertise` | TEXT |  — thêm ở 0161_person_profile_fields.sql |

**Khóa ngoại:** `family_id` → `families` · `user_id` → `users` · `linked_by` → `users` · `created_by` → `users`

**Index:** `idx_family_contacts_family`(family_id, status, display_order) · `idx_family_contacts_user`(family_id, user_id) UNIQUE · `idx_family_contacts_email`(email)

## `family_journey`

Migration 0081 — Family Journey: mỗi thành viên đang ở bước nào. SRC-589, SDD-026, REQ-DOR-07/08/09.  Idempotent hoàn toàn (CREATE TABLE/INDEX IF NOT EXISTS) — AS-04.1.2.  CHỈ lưu bước ĐÃ ĐƯỢC NGƯỜI XÁC NHẬN. Bước hệ suy ra tính tại chỗ mỗi lần đọc, không lưu: một bảng chứa kết quả suy luận sẽ đầy hàng lặp lại mỗi lần cron chạy (lỗi đã tránh ở SDD-024 §5).  Lịch sử KHÔNG có bảng riêng: mỗi lần xác nhận, service ghi thêm một note gắn `event` vào `family_notes`. Nhờ vậy dòng thời gian của journey nằm chung dòng thời gian của mọi thứ khác — đúng chỗ người ta sẽ đi tìm nó.  `stage` không có CHECK: SQLite không sửa được CHECK sau khi tạo bảng, mà bảy bước này gần như chắc chắn sẽ được đặt lại tên sau vài tháng dùng thật. Ràng buộc nằm ở zod enum trong API, cùng cách đã dùng cho `learners.learning_priority` (0045) và `family_notes.tags_json` (0080).

migration: `0081_family_journey.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `family_id` | TEXT | NOT NULL REFERENCES families(id) |
| `member_kind` | TEXT | NOT NULL CHECK (member_kind IN ('contact','learner')) |
| `member_id` | TEXT | NOT NULL |
| `stage` | TEXT | NOT NULL |
| `confirmed_by` | TEXT | NOT NULL REFERENCES users(id) |
| `confirmed_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (member_kind, member_id)` |

**Khóa ngoại:** `family_id` → `families` · `confirmed_by` → `users`

**Index:** `idx_family_journey_family`(family_id)

## `family_mentors`

Migration 0079 — Dolphin tự nhận chăm sóc một gia đình. SRC-585, SDD-023 §11. REQ-MEN-15.  Idempotent hoàn toàn (chỉ CREATE TABLE/INDEX IF NOT EXISTS) — AS-04.1.2.  VÌ SAO KHÔNG DÙNG LẠI `mentor_assignments` (0008): hai thứ khác nhau ở CẢ HAI trục.   mentor_assignments : tầng LEARNER, do ADMIN giao   → phân công chính thức   family_mentors     : tầng GIA ĐÌNH, do MENTOR tự nhận → "tôi đang chăm nhà này" Nhét chung một bảng thì mất khả năng phân biệt "được giao" với "tự nhận", mà đó đúng là thứ một người quản lý cần đọc được. Ngoài ra tầng gia đình còn làm được việc tầng learner không làm được: nhận chăm một nhà CHƯA có đứa con nào trong hệ (mentor vừa dựng hồ sơ, con chưa vào).

migration: `0079_family_mentors.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `family_id` | TEXT | NOT NULL REFERENCES families(id) |
| `mentor_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','ended')) |
| `note` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (family_id, mentor_user_id)` |

**Khóa ngoại:** `family_id` → `families` · `mentor_user_id` → `users`

**Index:** `idx_family_mentors_mentor`(mentor_user_id, status) · `idx_family_mentors_family`(family_id, status)

## `family_note_tag_suggestions`

Đo tỉ lệ chấp nhận đề xuất tag của AI (SDD-024 §6 ràng buộc 4). Không có bảng này thì không ai biết tag nào đang bị từ chối liên tục — mà đó là dấu hiệu ĐỊNH NGHĨA TAG hoặc PROMPT sai, không phải người dùng sai.

migration: `0080_family_notes.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `note_id` | TEXT | NOT NULL REFERENCES family_notes(id) |
| `tag` | TEXT | NOT NULL |
| `action` | TEXT | NOT NULL CHECK (action IN ('add','remove')) |
| `outcome` | TEXT | NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','accepted','rejected')) |
| `decided_by` | TEXT | REFERENCES users(id) |
| `decided_at` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `note_id` → `family_notes` · `decided_by` → `users`

**Index:** `idx_note_tag_suggestions_tag`(tag, outcome)

## `family_notes`

Migration 0080 — Dory: ghi chép gia đình theo mô hình NOTE + TAG. SRC-587/588, SDD-024 §2, REQ-DOR-01..06.  Idempotent hoàn toàn (chỉ CREATE TABLE/INDEX IF NOT EXISTS) — AS-04.1.2.  MỘT BẢNG. Chỉ đạo chủ dự án 2026-08-26: "làm sao để số lượng fields, và số lượng thông tin vô cùng ít… nhiều notes, mỗi note gắn một hoặc vài tags". Bản phác đầu của SDD-024 có năm bảng và hơn ba mươi cột; nó đã bị thay bằng đúng bảng này.  KHÔNG có `family_visits`: một buổi thăm nhà là một note gắn `event`. Bảng thứ hai chỉ đáng tồn tại khi có truy vấn riêng cho nó, mà hiện không có. KHÔNG có cột `visibility`: ghi chú gia đình là nội bộ đội Dolphin (Q42 chưa làm cờ khoá). Thêm một cột quyền cho một luật chưa tồn tại là thêm một cột sẽ để sai.

migration: `0080_family_notes.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `family_id` | TEXT | NOT NULL REFERENCES families(id) |
| `body` | TEXT | NOT NULL |
| `tags_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `learner_id` | TEXT | REFERENCES learners(id) |
| `reply_to_id` | TEXT | REFERENCES family_notes(id) |
| `author_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')) |
| `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')) |
| `contact_id` | TEXT | REFERENCES family_contacts(id) — thêm ở 0160_family_notes_contact.sql |
| `channel` | TEXT |  — thêm ở 0208_family_contact_log.sql |
| `happened_on` | TEXT |  — thêm ở 0208_family_contact_log.sql |

**Khóa ngoại:** `family_id` → `families` · `learner_id` → `learners` · `reply_to_id` → `family_notes` · `author_user_id` → `users`

**Index:** `idx_family_notes_family`(family_id, status, created_at DESC) · `idx_family_notes_reply`(reply_to_id) · `idx_family_notes_learner`(learner_id) · `idx_family_notes_contact`(contact_id) · `idx_family_notes_contact_log`(family_id, happened_on DESC)

## `family_photos`

Ảnh của gia đình — bốn trường chỉ đạo nêu đích danh đều là CỘT RIÊNG, không nhét chung vào một ô ghi chú: có cột thì sắp theo thời gian được, lọc theo buổi được, và ba năm nữa vẫn biết tấm ảnh chụp ở đâu.  taken_at do NGƯỜI NHẬP khai, không đọc EXIF: ảnh chuyển qua Zalo thường đã bị xoá sạch EXIF nên đọc EXIF cho ra "không có ngày" ở phần lớn ảnh thật (SDD-023 §6). location là CHỮ chứ không phải toạ độ (Q-147) — toạ độ gắn với một đứa trẻ là dữ liệu nhạy cảm hơn hẳn một dòng chữ, và SDD-009 §5 vốn yêu cầu XOÁ EXIF chứ không phải khai thác nó.  alt_text cho phép NULL ở đây, khác photo_album_items (NOT NULL). Khác nhau có lý do: album là trang công khai, thiếu alt là trang trống với người dùng trình đọc màn hình; còn đây là kho nội bộ, bắt gõ alt cho từng tấm trong một lượt tải 40 ảnh thì thực tế là mentor gõ "a" cho đủ thủ tục. Khi hiển thị, alt rơi về caption.

migration: `0078_family_workspace.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `family_id` | TEXT | NOT NULL REFERENCES families(id) |
| `media_id` | TEXT | NOT NULL REFERENCES media(id) |
| `caption` | TEXT | — |
| `description` | TEXT | — |
| `taken_at` | TEXT | — |
| `location` | TEXT | — |
| `alt_text` | TEXT | — |
| `uploaded_by` | TEXT | NOT NULL REFERENCES users(id) |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')) |
| `display_order` | INTEGER | NOT NULL DEFAULT 100 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `source` | TEXT |  — thêm ở 0176_family_photo_meta.sql |
| `people_json` | TEXT |  — thêm ở 0176_family_photo_meta.sql |

**Khóa ngoại:** `family_id` → `families` · `media_id` → `media` · `uploaded_by` → `users`

**Index:** `idx_family_photos_family`(family_id, status, taken_at DESC)

## `foundry_spend_ledger`

Migration 0329 - foundry spend ledger. SRC-1225.  ## Vì sao migration này tồn tại  Trần chi phí ngày của xưởng nội dung (FOUNDRY_DAILY_USD_CAP) cộng `content_run_trials` và `content_item_drafts`, mà hai bảng ấy chỉ giữ chi phí của LẦN THỬ CUỐI mỗi step: Workflows retry một step hỏng tới 2 lần và chi phí các lần trước mất theo exception. Một model trượt liên tục bị tính tiền ba lần nhưng sổ ghi một, nên trần báo xanh trong khi tiền thật đã vượt (audit 03.10.2026). Sổ này ghi MỖI lượt gọi model ngay trong `callModel`, theo thời điểm gọi thật chứ không theo giờ bắt đầu run, và trần đọc từ đây.  ## Chạy lại không đổi gì  `CREATE TABLE IF NOT EXISTS` + index `IF NOT EXISTS`. Không nạp dữ liệu cũ: trong giai đoạn chuyển tiếp, trần lấy MAX của sổ này và cách cộng cũ.

migration: `0329_foundry_spend_ledger.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `run_id` | TEXT | NOT NULL |
| `model_key` | TEXT | NOT NULL |
| `step` | TEXT | — |
| `usd` | REAL | NOT NULL DEFAULT 0 |
| `ok` | INTEGER | NOT NULL DEFAULT 0 |
| `at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Index:** `idx_foundry_spend_ledger_at`(at)

## `grammar_live_recordings`

Một buổi một bản ghi. Nạp bằng workflow seed-data sau mỗi buổi (không có màn admin ở vòng này).

migration: `0340_grammar_live_signups_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | PRIMARY KEY |
| `title` | TEXT | NOT NULL |
| `url` | TEXT | NOT NULL CHECK (url LIKE 'https://%') |
| `added_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
