Skip to content

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. 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
draft_idTEXTNOT NULL REFERENCES essay_drafts(id)
version_idTEXTREFERENCES essay_draft_versions(id)
learner_idTEXTNOT NULL REFERENCES learners(id)
author_user_idTEXTNOT NULL REFERENCES users(id)
kindTEXTNOT NULL CHECK (kind IN ('question', 'observation', 'rubric'))
skillTEXT—
anchor_startINTEGERNOT NULL DEFAULT 0
anchor_endINTEGERNOT NULL DEFAULT 0
anchor_textTEXTNOT NULL DEFAULT ''
bodyTEXTNOT NULL
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
amount_vndINTEGERNOT NULL CHECK (amount_vnd >= 0)
transfer_refTEXTNOT NULL UNIQUE
statusTEXTNOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'cancelled'))
activation_codeTEXTUNIQUE
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
paid_atTEXT—
paid_byTEXT—

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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
draft_idTEXTNOT NULL REFERENCES essay_drafts(id)
learner_idTEXTNOT NULL REFERENCES learners(id)
version_noINTEGERNOT NULL CHECK (version_no >= 1)
session_keyTEXTNOT NULL
bodyTEXTNOT NULL
word_countINTEGERNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintUNIQUE (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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
prompt_idTEXTNOT NULL
bodyTEXTNOT NULL DEFAULT ''
word_countINTEGERNOT NULL DEFAULT 0
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintUNIQUE (learner_id, prompt_id)

Khóa ngoại: learner_id → learners

essay_drill_attempts ​

migration: 0317_essay_program.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
drill_idTEXTNOT NULL
answerTEXTNOT NULL
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id)
activation_codeTEXTNOT NULL UNIQUE
activated_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
story_idTEXTNOT NULL REFERENCES essay_stories(id)
rungINTEGERNOT NULL CHECK (rung BETWEEN 1 AND 6)
learner_idTEXTNOT NULL REFERENCES learners(id)
answerTEXTNOT NULL
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
essay_idTEXTNOT NULL
from_idxINTEGERNOT NULL CHECK (from_idx >= 0)
to_idxINTEGERNOT NULL CHECK (to_idx >= from_idx)
noteTEXTNOT NULL DEFAULT ''
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
kindTEXTNOT NULL
ref_idTEXT—
detailTEXTNOT NULL DEFAULT '{}'
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
prompt_idTEXTNOT NULL
titleTEXTNOT NULL
bodyTEXTNOT NULL
themesTEXTNOT NULL DEFAULT '[]'
skillsTEXTNOT NULL DEFAULT '[]'
essay_typesTEXTNOT NULL DEFAULT '[]'
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

Index: idx_essay_stories_learner(learner_id, updated_at)

event_activities ​

migration: 0215_event_interactions.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
event_idTEXTNOT NULL
kindTEXTNOT NULL CHECK (kind IN ('question', 'poll', 'reflection_4f', 'reflection_ssc'))
titleTEXTNOT NULL
promptTEXTNOT NULL DEFAULT '', -- mô tả thêm, hiện dưới tiêu đề
options_jsonTEXT, -- poll: ["Phương án 1", …]; question: NULL
max_answers_per_personINTEGER—
allow_upvoteINTEGERNOT NULL DEFAULT 1 CHECK (allow_upvote IN (0, 1))
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'open', 'closed'))
positionINTEGERNOT NULL DEFAULT 0
is_ask_hostINTEGERNOT NULL DEFAULT 0 CHECK (is_ask_host IN (0, 1))
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
activity_idTEXTNOT NULL
event_idTEXTNOT NULL
user_idTEXTNOT NULL
display_nameTEXT, -- chụp tên lúc gửi, để Live Stage không phải join users
slotTEXT—
bodyTEXTNOT NULL
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
activity_idTEXTNOT NULL
event_idTEXTNOT NULL
user_idTEXTNOT NULL
display_nameTEXT—
started_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
answer_idTEXTNOT NULL
user_idTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
event_idTEXTNOT NULL REFERENCES community_events(id) ON DELETE CASCADE
template_idTEXTNOT NULL
phaseTEXTNOT NULL CHECK (phase IN ('before','during','after'))
sizeTEXTNOT NULL DEFAULT 'square' CHECK (size IN ('square','portrait','landscape'))
brandTEXT—
fields_jsonTEXTNOT NULL DEFAULT '{}'
sortINTEGERNOT NULL DEFAULT 100
created_byTEXT—
updated_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
archived_atTEXT—

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ộtKiểuRàng buộc / ghi chú
event_idTEXTNOT NULL REFERENCES community_events(id) ON DELETE CASCADE
task_idTEXTNOT NULL REFERENCES event_checklist_tasks(id)
doneINTEGERNOT NULL DEFAULT 0
noteTEXT—
updated_byTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
phaseTEXTNOT NULL CHECK (phase IN ('before','during','after'))
areaTEXTNOT NULL CHECK (area IN ('comms','guests','logistics'))
labelTEXTNOT NULL
sortINTEGERNOT NULL DEFAULT 100
created_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
archived_atTEXT—

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ộtKiểuRàng buộc / ghi chú
activity_idTEXTNOT NULL
user_idTEXTNOT NULL
option_indexINTEGERNOT NULL
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
event_idTEXTNOT NULL
kindTEXTNOT NULL CHECK (kind IN ('pdf', 'image', 'link'))
titleTEXTNOT NULL
urlTEXT—
mime_typeTEXT—
byte_sizeINTEGER—
positionINTEGERNOT NULL DEFAULT 0
uploaded_byTEXTREFERENCES users(id)
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
—table constraintCHECK ((kind = 'link' AND url IS NOT NULL AND r2_key IS NULL)
—table constraintCHECK (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ộtKiểuRàng buộc / ghi chú
event_idTEXTPRIMARY KEY
youtube_video_idTEXTNOT NULL
captionTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
—table constraintCHECK (length(youtube_video_id) = 11 AND youtube_video_id NOT GLOB '*[^A-Za-z0-9_-]*')
—table constraintCHECK (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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
target_typeTEXTNOT NULL CHECK (target_type IN ('lab','learning','assessment','item','unit'))
target_idTEXTNOT NULL
subject_idTEXT—
node_idTEXT—
step_refTEXT—
verdictTEXTNOT NULL CHECK (verdict IN ('ok','minor','major','blocker'))
tags_jsonTEXTNOT NULL DEFAULT '[]'
noteTEXT—
reviewer_user_idTEXTNOT NULL REFERENCES users(id)
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
family_idTEXTNOT NULL REFERENCES families(id)
roleTEXTNOT NULL DEFAULT 'guardian' CHECK (role IN ('father','mother','guardian','other'))
full_nameTEXT—
phoneTEXT—
zaloTEXT—
emailTEXT—
occupationTEXT—
workplaceTEXT—
addressTEXT—
districtTEXT—
provinceTEXT—
noteTEXT—
user_idTEXTREFERENCES users(id)
linked_atTEXT—
linked_byTEXTREFERENCES users(id)
created_byTEXTNOT NULL REFERENCES users(id)
sourceTEXTNOT NULL DEFAULT 'mentor' CHECK (source IN ('mentor','self'))
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
display_orderINTEGERNOT NULL DEFAULT 100
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
photo_media_idTEXT— thêm ở 0083_family_member_photos_nickname.sql
facebook_urlTEXT— thêm ở 0161_person_profile_fields.sql
links_jsonTEXT— thêm ở 0161_person_profile_fields.sql
interestsTEXT— thêm ở 0161_person_profile_fields.sql
expertiseTEXT— 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ộtKiểuRàng buộc / ghi chú
family_idTEXTNOT NULL REFERENCES families(id)
member_kindTEXTNOT NULL CHECK (member_kind IN ('contact','learner'))
member_idTEXTNOT NULL
stageTEXTNOT NULL
confirmed_byTEXTNOT NULL REFERENCES users(id)
confirmed_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
family_idTEXTNOT NULL REFERENCES families(id)
mentor_user_idTEXTNOT NULL REFERENCES users(id)
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','ended'))
noteTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
note_idTEXTNOT NULL REFERENCES family_notes(id)
tagTEXTNOT NULL
actionTEXTNOT NULL CHECK (action IN ('add','remove'))
outcomeTEXTNOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','accepted','rejected'))
decided_byTEXTREFERENCES users(id)
decided_atTEXT—
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
family_idTEXTNOT NULL REFERENCES families(id)
bodyTEXTNOT NULL
tags_jsonTEXTNOT NULL DEFAULT '[]'
learner_idTEXTREFERENCES learners(id)
reply_to_idTEXTREFERENCES family_notes(id)
author_user_idTEXTNOT NULL REFERENCES users(id)
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
contact_idTEXTREFERENCES family_contacts(id) — thêm ở 0160_family_notes_contact.sql
channelTEXT— thêm ở 0208_family_contact_log.sql
happened_onTEXT— 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
family_idTEXTNOT NULL REFERENCES families(id)
media_idTEXTNOT NULL REFERENCES media(id)
captionTEXT—
descriptionTEXT—
taken_atTEXT—
locationTEXT—
alt_textTEXT—
uploaded_byTEXTNOT NULL REFERENCES users(id)
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
display_orderINTEGERNOT NULL DEFAULT 100
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
sourceTEXT— thêm ở 0176_family_photo_meta.sql
people_jsonTEXT— 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ộtKiểuRàng buộc / ghi chú
idINTEGERPRIMARY KEY AUTOINCREMENT
run_idTEXTNOT NULL
model_keyTEXTNOT NULL
stepTEXT—
usdREALNOT NULL DEFAULT 0
okINTEGERNOT NULL DEFAULT 0
atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
session_idTEXTPRIMARY KEY
titleTEXTNOT NULL
urlTEXTNOT NULL CHECK (url LIKE 'https://%')
added_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))