Skip to content

Data Dictionary · Khác (phần 11/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. 22 bảng, speak_schedule_signups tới walk_registrations.

speak_schedule_signups ​

Migration 0303 - Đăng ký buổi Speak together theo lịch tuần. SRC-1088. Chủ dự án 27.09.2026: lịch luyện Speaking hằng tuần, tối thứ Tư 20:00-21:30 và sáng Chủ nhật 10:00-11:30 (giờ VN); learner đăng ký bất kỳ buổi nào, chậm nhất 48 giờ trước giờ bắt đầu; xem lại được các buổi đã đăng ký và chi tiết từng buổi. ## Vì sao chỉ một bảng đăng ký, không có bảng buổi Buổi là một LỊCH CỐ ĐỊNH (hai khung giờ mỗi tuần, chủ đề xoay vòng theo thứ tự), nên máy chủ tính ra được mọi buổi từ một mốc (speakSchedule.ts) mà không cần lưu. Lưu từng buổi thành dòng thì phải có một việc định kỳ sinh dòng trước, và ngày việc ấy hỏng là trang lịch trống. Thứ duy nhất phải nhớ là ai đã đăng ký buổi nào; session_id là mã buổi dạng '2026-09-30-2000' (ngày giờ VN). ## Chạy lại không đổi gì CREATE TABLE / INDEX IF NOT EXISTS.

migration: 0303_speak_schedule_signups.sql

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

Khóa ngoại: learner_id → learners

Index: idx_speak_schedule_learner(learner_id, session_id)

speak_trial_signups ​

Migration 0310 - Buổi học thử của program Speaking. SRC-1120. ## Vì sao migration này tồn tại Chủ dự án 28.09.2026: program Speaking ở learn.nemo12.com/speaking cho mỗi learner 3 buổi học thử, đăng ký trong tháng 9 hoặc tháng 10/2026, chỉ các buổi tối thứ Tư. Buổi thử vẫn là một dòng speak_schedule_signups như mọi lượt đăng ký (danh sách người đến, nhắc lịch, link Zoom dùng chung); bảng này chỉ ĐÁNH DẤU lượt nào đi qua program Speaking, để máy chủ đếm được đã dùng mấy buổi thử. Bảng riêng chứ không thêm cột: ALTER TABLE ADD COLUMN chạy lại sẽ lỗi, còn bảng thì chạy lại vô hại. ## Chạy lại không đổi gì CREATE TABLE / INDEX IF NOT EXISTS.

migration: 0310_speak_schedule_signups_program.sql

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

Khóa ngoại: learner_id → learners

Index: idx_speak_trial_learner(learner_id)

specialized_programs ​

Lớp chuyên. subject_code cố ý KHÔNG khoá ngoại vào subjects của knowledge graph: lớp chuyên có Sinh, Sử, Địa, Nga, Nhật… trong khi graph mới có 6 môn. Khoá ngoại ở đây nghĩa là không nhập nổi lớp Chuyên Sinh cho tới ngày có content môn Sinh (SDD-020 §2).

migration: 0047_school_registry.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
school_idTEXTNOT NULL REFERENCES target_schools(id)
subject_codeTEXTNOT NULL
name_viTEXTNOT NULL
intake_sizeINTEGER—
notesTEXT—
statusTEXTNOT NULL DEFAULT 'published' CHECK (status IN ('draft','published','archived'))
display_orderINTEGERNOT NULL DEFAULT 100
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: school_id → target_schools

Index: idx_specialized_programs_school(school_id, display_order)

testimonials ​

Migration 0075 — Cảm nhận của người dùng (SRC-564, mở rộng SDD-019). Chỉ đạo: cần chỗ cho người xem thấy RẤT NHIỀU cảm nhận thật, để tăng niềm tin. Ảnh cộng đồng đã có chỗ từ 0046 (photo_albums); phần còn thiếu là lời của người thật. Vì sao KHÔNG nhét vào một bảng "content" chung: cảm nhận là lời khẳng định của MỘT NGƯỜI CÓ THẬT về sản phẩm. Nó cần đúng ba thứ mà nội dung marketing không cần: 1. consent — người đó đã đồng ý cho nêu tên chưa. Đăng lời khen của một phụ huynh mà chưa hỏi là chuyện pháp lý, không phải chuyện biên tập. 2. verified — ai trong đội đã đối chiếu lời này là thật, và lúc nào. 3. audience — cùng một câu chuyện kể cho bố mẹ khác với kể cho học sinh (SDD-019 §6). Cổng publish ở tầng API đòi đủ consent + verified, giống studentPublishBlockers của SDD-020.

migration: 0075_testimonials.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
author_nameTEXTNOT NULL
author_roleTEXT—
photo_media_idTEXTREFERENCES media(id)
quoteTEXTNOT NULL
bodyTEXT—
program_codeTEXT—
mentor_idTEXTREFERENCES mentor_profiles(id)
audienceTEXTNOT NULL DEFAULT 'both' CHECK (audience IN ('both','parent','student'))
consentINTEGERNOT NULL DEFAULT 0 CHECK (consent IN (0,1))
consent_noteTEXT—
verifiedINTEGERNOT NULL DEFAULT 0 CHECK (verified IN (0,1))
verified_atTEXT—
verified_byTEXTREFERENCES users(id)
internal_noteTEXT—
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived'))
featuredINTEGERNOT NULL DEFAULT 0 CHECK (featured IN (0,1))
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'))
said_onTEXT— thêm ở 0175_testimonial_card_fields.sql
facts_jsonTEXTNOT NULL DEFAULT '[]' — thêm ở 0175_testimonial_card_fields.sql
is_sampleINTEGERNOT NULL DEFAULT 0 — thêm ở 0175_testimonial_card_fields.sql
kindTEXTNOT NULL DEFAULT 'quote' — thêm ở 0199_testimonial_stories.sql
slugTEXT— thêm ở 0199_testimonial_stories.sql
titleTEXT— thêm ở 0199_testimonial_stories.sql
summaryTEXT— thêm ở 0199_testimonial_stories.sql
cover_media_idTEXTREFERENCES media(id) — thêm ở 0199_testimonial_stories.sql
content_stateTEXTNOT NULL DEFAULT 'draft' — thêm ở 0199_testimonial_stories.sql
visibilityTEXTNOT NULL DEFAULT 'private' — thêm ở 0199_testimonial_stories.sql
placements_jsonTEXTNOT NULL DEFAULT '[]' — thêm ở 0199_testimonial_stories.sql
source_note_idTEXTREFERENCES family_notes(id) — thêm ở 0216_testimonial_from_note.sql

Khóa ngoại: photo_media_id → media · mentor_id → mentor_profiles · verified_by → users

Index: idx_testimonials_public(status, audience, featured DESC, display_order) · idx_testimonials_program(program_code, status) · idx_testimonials_slug(slug) UNIQUE · idx_testimonials_kind(kind, status, display_order) · idx_testimonials_source_note(source_note_id) UNIQUE

tp_agent_tokens ​

0189 — Tool Plane: nhiều token cho một agent, mỗi token có thể trói vào một user (SRC-667, SDD-030 §6). Vì sao: Claude Managed Agents chỉ gửi được Authorization: Bearer <token> tới MCP server, KHÔNG gửi được header tuỳ ý — nên X-Nemo12-User-Id không có đường đi. Người mà Agent thay mặt phải nằm trong chính token: một token = (agent, user). Mỗi vault của Managed Agents giữ một token, và vault vốn đã là "một người dùng", nên mô hình khớp nhau. tp_agents.token_hash (0188) vẫn dùng được như token không trói user (đường cũ).

migration: 0189_tool_plane_agent_tokens.sql

CộtKiểuRàng buộc / ghi chú
token_hashTEXTPRIMARY KEY
agent_idTEXTNOT NULL REFERENCES tp_agents(id)
user_idTEXTREFERENCES users(id), -- NULL = token không thay mặt ai (job nền, research)
labelTEXT—
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
last_used_atTEXT—

Khóa ngoại: agent_id → tp_agents · user_id → users

Index: idx_tp_agent_tokens_agent(agent_id, status)

tp_agent_tools ​

Grant: agent nào được tool nào, trên tài nguyên nào, ở môi trường nào, đọc hay ghi. tool_id nhận cả learner.get_profile:v1, learner.get_profile và glob learner.*.

migration: 0188_tool_plane.sql

CộtKiểuRàng buộc / ghi chú
agent_idTEXTNOT NULL REFERENCES tp_agents(id)
tool_idTEXTNOT NULL
resource_scopeTEXTNOT NULL DEFAULT 'user', -- '*' | 'user' | 'learner:L1,learner:L2' | 'family:F1'
environment_scopeTEXTNOT NULL DEFAULT '', -- '' | 'production' | 'staging,development'
permissionTEXTNOT NULL DEFAULT 'read' CHECK (permission IN ('read','write'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (agent_id, tool_id)

Khóa ngoại: agent_id → tp_agents

tp_agents ​

Agent = một danh tính gọi tool. token_hash NULL = chưa cấp token, không đăng nhập được. Cấp token: node scripts/tool-plane/issue-agent-token.mjs <agent_id> → in token + SQL cập nhật hash.

migration: 0188_tool_plane.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
nameTEXTNOT NULL
descriptionTEXT—
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended','retired'))
token_hashTEXTUNIQUE
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

tp_applications ​

Application Registry (spec §4): Agent suy luận về hệ Nemo12 từ đây, không hardcode.

migration: 0188_tool_plane.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
nameTEXTNOT NULL
domainTEXT—
repositoryTEXT—
repository_pathTEXT—
environmentTEXTNOT NULL DEFAULT 'production'
ownerTEXT—
statusTEXTNOT NULL DEFAULT 'live' CHECK (status IN ('planned','live','deprecated'))
descriptionTEXT—
architecture_referenceTEXT—
databaseTEXT—
deployment_targetTEXT—
metadataTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

tp_audit_logs ​

Một dòng cho MỖI lượt gọi, kể cả lượt bị từ chối. Không chứa input/output.

migration: 0188_tool_plane.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
request_idTEXTNOT NULL
timestampTEXTNOT NULL
agent_idTEXTNOT NULL
session_idTEXTNOT NULL
user_idTEXT—
tool_idTEXTNOT NULL
resourceTEXT—
decisionTEXTNOT NULL CHECK (decision IN ('allow','deny','require_approval'))
statusTEXTNOT NULL CHECK (status IN ('success','error','denied'))
error_codeTEXT—
duration_msINTEGERNOT NULL DEFAULT 0
environmentTEXTNOT NULL
metadataTEXT—

Index: idx_tp_audit_agent_time(agent_id, timestamp DESC) · idx_tp_audit_resource(resource, timestamp DESC) · idx_tp_audit_request(request_id)

tp_policies ​

Policy toàn hệ. deny thắng require_approval thắng allow (policy-engine.ts).

migration: 0188_tool_plane.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
nameTEXTNOT NULL
effectTEXTNOT NULL CHECK (effect IN ('allow','deny','require_approval'))
agent_scopeTEXTNOT NULL DEFAULT '*'
tool_scopeTEXTNOT NULL DEFAULT '*'
resource_scopeTEXTNOT NULL DEFAULT '*'
environment_scopeTEXTNOT NULL DEFAULT '*'
conditionsTEXT, -- JSON: {"risk_at_least":"HIGH"} |
enabledINTEGERNOT NULL DEFAULT 1
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

tp_tools ​

Bản chiếu của Tool Registry. CODE (workers/tool-plane/src/registry/tools.ts) là nguồn sự thật; bảng này để grant/policy tham chiếu và để người vận hành đọc. Khớp lại bằng node scripts/tool-plane/sync-tools.mjs.

migration: 0188_tool_plane.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY, -- learner.get_profile:v1
nameTEXTNOT NULL, -- learner.get_profile
descriptionTEXT—
categoryTEXTNOT NULL
versionINTEGERNOT NULL DEFAULT 1
risk_levelTEXTNOT NULL CHECK (risk_level IN ('LOW','MEDIUM','HIGH','CRITICAL'))
modeTEXTNOT NULL CHECK (mode IN ('read','write'))
enabledINTEGERNOT NULL DEFAULT 1
input_schemaTEXT—
output_schemaTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

tutor_digest_items ​

migration: 0321_tutor_learner_profile_and_weekly_digest.sql

CộtKiểuRàng buộc / ghi chú
family_listing_idTEXTNOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE
tutor_listing_idTEXTNOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE
sent_atTEXTNOT NULL
—table constraintPRIMARY KEY (family_listing_id, tutor_listing_id)

Khóa ngoại: family_listing_id → tutor_listings · tutor_listing_id → tutor_listings

Index: idx_tutor_digest_items_sent(family_listing_id, sent_at)

tutor_interests ​

Một lượt "Quan tâm" từ listing này tới listing phía kia. Một cặp (from, to) một dòng: bấm lại không tạo dòng mới.

migration: 0315_tutor_listings_and_interests.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
from_listing_idTEXTNOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE
to_listing_idTEXTNOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE
from_user_idTEXTNOT NULL, -- để đếm trần 10 lượt/ngày theo người, không theo listing
reasonsTEXTNOT NULL, -- JSON mảng mã lý do
messageTEXT, -- tối đa 300 ký tự, đã lọc số điện thoại, email, URL
statusTEXTNOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
responded_atTEXT—

Khóa ngoại: from_listing_id → tutor_listings · to_listing_id → tutor_listings

Index: idx_tutor_interests_pair(from_listing_id, to_listing_id) UNIQUE · idx_tutor_interests_to(to_listing_id, status) · idx_tutor_interests_user_day(from_user_id, created_at)

tutor_listing_contacts ​

Phần RIÊNG TƯ: chỉ chủ listing và admin đọc được.

migration: 0315_tutor_listings_and_interests.sql

CộtKiểuRàng buộc / ghi chú
listing_idTEXTPRIMARY KEY REFERENCES tutor_listings(id) ON DELETE CASCADE
contact_nameTEXTNOT NULL
phoneTEXTNOT NULL
emailTEXTNOT NULL
learner_nameTEXT— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql
schoolTEXT— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql

Khóa ngoại: listing_id → tutor_listings

tutor_listings ​

Migration 0315 - tutor listings and interests. SRC-1141, SDD-050. ## Vì sao migration này tồn tại Chủ dự án 29.09.2026: NEMO TUTOR là hệ listing hai phía ở learn.nemo12.com/tutors. Gia đình và gia sư mỗi bên điền một form ngắn; mỗi bên chỉ xem listing của phía kia, ẩn danh; muốn làm quen thì bấm "Quan tâm", hai bên cùng quan tâm thì admin kết nối bằng tay. Không bên nào thấy thông tin liên hệ của bên kia qua hệ thống. Vì thế thông tin liên hệ (họ tên, điện thoại, email) nằm ở một BẢNG RIÊNG, tutor_listing_contacts, chứ không phải vài cột cạnh phần công khai: câu SELECT của trang duyệt listing đọc tutor_listings và không có đường nào kéo nhầm số điện thoại ra, kể cả khi ai đó viết SELECT *. Chỉ hai chỗ đọc bảng liên hệ: trang "listing của tôi" (chính chủ) và hàng chờ admin. Xoá listing là xoá cả dòng liên hệ, các lượt quan tâm và các cặp đã khớp của listing ấy (mã trong workers/api/src/modules/tutors/ xoá tường minh, không dựa vào ON DELETE CASCADE vì D1 chỉ thi hành khoá ngoại khi PRAGMA foreign_keys bật). ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.

migration: 0315_tutor_listings_and_interests.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
user_idTEXTNOT NULL REFERENCES users(id)
sideTEXTNOT NULL CHECK (side IN ('family', 'tutor'))
public_noINTEGERNOT NULL
statusTEXTNOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'hidden', 'paused'))
goalsTEXTNOT NULL, -- JSON mảng mã mục tiêu: ielts, sat, chuyen, university, subject, other
subjectsTEXTNOT NULL, -- môn / phần cần giúp (gia đình) hoặc môn dạy (gia sư)
areaTEXTNOT NULL, -- quận Hà Nội hoặc 'online'
onlineINTEGERNOT NULL DEFAULT 0,-- nhận học online
scheduleTEXTNOT NULL, -- lịch mong muốn (gia đình) hoặc lịch rảnh (gia sư)
noteTEXT, -- ghi chú ngắn (gia đình) hoặc giới thiệu ngắn (gia sư)
family_roleTEXTCHECK (family_role IS NULL OR family_role IN ('student', 'parent'))
gradeTEXT—
levelTEXT—
evidenceTEXT, -- gia sư: mỗi dòng một bằng chứng
experienceTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
approved_atTEXT—
birth_yearINTEGER— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql
targetTEXT— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql
exam_monthTEXT— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql
sessions_per_weekINTEGER— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql
budgetTEXT— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql
closed_atTEXT— thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql

Khóa ngoại: user_id → users

Index: idx_tutor_listings_user_side(user_id, side) UNIQUE · idx_tutor_listings_label(side, public_no) UNIQUE · idx_tutor_listings_browse(side, status, updated_at)

tutor_matches ​

Hai bên cùng quan tâm: một cặp đã khớp, chờ admin kết nối bằng tay.

migration: 0315_tutor_listings_and_interests.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
family_listing_idTEXTNOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE
tutor_listing_idTEXTNOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE
statusTEXTNOT NULL DEFAULT 'matched' CHECK (status IN ('matched', 'connected'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
connected_atTEXT—
connected_byTEXT—

Khóa ngoại: family_listing_id → tutor_listings · tutor_listing_id → tutor_listings

Index: idx_tutor_matches_pair(family_listing_id, tutor_listing_id) UNIQUE · idx_tutor_matches_status(status, created_at)

unit_prereqs ​

Migration 0055 — prerequisite graph ở cấp UNIT (SRC-396). Chỉ đạo chủ dự án 2026-08-19: "khi làm không ổn 1 unit thì đi học lại 1-2 unit trước đó, không tính theo node hay concept nữa" — và làm rõ tiếp: đó là 1-2 unit CHẶN unit hiện tại, tức quan hệ ngữ nghĩa, không phải vị trí đứng cạnh nhau trên bản đồ. Vì sao bảng mới chứ không dùng skill_edges: · skill_edges làm việc ở cấp node, mà một unit có tới ba node là biến thể theo lớp của cùng một ý. Sinh cạnh ở đó buộc phải chọn "node chốt", và chính chỗ chọn ấy đẻ ra 53/171 prerequisite đi ngược lớp (lớp 7 thành nền của lớp 2) trong lần sinh trước — sai mà không làm gãy gì, nên không ai thấy cho tới khi đi đếm. · Ở cấp unit thì mỗi cạnh là một câu người đọc được và bác được, và cả prerequisite graph chỉ còn ~150-250 dòng — rà bằng mắt hết được, khác hẳn 1.540 câu hỏi. Cạnh ĐƯỢC PHÉP đi xuyên Package: đó là lý do soạn tay thay vì sinh theo thứ tự bày. "Giải phương trình một ẩn" (B2) đứng trên "Dấu bằng nghĩa là hai bên cân nhau" (B1) — cách nhau cả Package, và vị trí trên bản đồ không đời nào tìm ra quan hệ đó. Mặc định là hệ tự soạn tự chốt (REQ-PLT-12, AI-First): cạnh sinh ra ở trạng thái 'auto' và CÓ HIỆU LỰC NGAY. Coral không phải cổng duyệt, mà là chỗ người vào xem lại và nâng cấp sau: auto — hệ soạn, đang chạy; reviewed — người đã đọc và giữ nguyên; edited — người đã sửa (rationale ghi lại vì sao); rejected — người bỏ; runtime KHÔNG dùng, nhưng giữ dòng lại để lần sinh sau không đề xuất lại.

migration: 0055_unit_prereqs.sql

CộtKiểuRàng buộc / ghi chú
subject_idTEXTNOT NULL
unit_keyTEXTNOT NULL, -- 'strand|module|unit', khớp unitKeyOf() ở apps/learn
prereq_keyTEXTNOT NULL
rankINTEGERNOT NULL DEFAULT 1, -- 1 = prerequisite gần nhất, 2 = cái thứ hai
rationale_viTEXT, -- một câu: vì sao unit kia là prerequisite của unit này
statusTEXTNOT NULL DEFAULT 'auto', -- auto | reviewed | edited | rejected
sourceTEXTNOT NULL DEFAULT 'ai-claude'
reviewed_byTEXT—
reviewed_atTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (subject_id, unit_key, prereq_key)

Index: idx_unit_prereqs_unit(subject_id, unit_key, status) · idx_unit_prereqs_prereq(subject_id, prereq_key, status)

user_program_interest ​

migration: 0336_inbound_mail_log_and_user_program_interest.sql

CộtKiểuRàng buộc / ghi chú
user_idTEXTNOT NULL REFERENCES users(id)
programTEXTNOT NULL
first_seen_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
last_seen_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
viewsINTEGERNOT NULL DEFAULT 0
submitsINTEGERNOT NULL DEFAULT 0
signup_entryINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (user_id, program)

Khóa ngoại: user_id → users

Index: idx_user_program_interest_recent(user_id, last_seen_at DESC) · idx_user_program_interest_program(program, last_seen_at DESC)

walk_checkins ​

NEMO WALK: check-in tại điểm tập trung và ghép cặp (chủ dự án 01.10.2026). Mỗi learner một dòng mỗi buổi. partner_id NULL nghĩa là đang chờ người ghép; ghép là hai chiều (A trỏ B và B trỏ A). paired_by = 'auto' khi hệ thống ghép, 'host' khi host đổi cặp. Idempotent: chạy lại vô hại.

migration: 0322_walk_checkins_and_pairs.sql

CộtKiểuRàng buộc / ghi chú
walk_dateTEXTNOT NULL
learner_idTEXTNOT NULL REFERENCES learners(id)
checked_in_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
partner_idTEXTREFERENCES learners(id)
paired_byTEXTCHECK (paired_by IN ('auto', 'host'))
paired_atTEXT—
—table constraintPRIMARY KEY (walk_date, learner_id)

Khóa ngoại: learner_id → learners · partner_id → learners

Index: idx_walk_checkins_waiting(walk_date, partner_id, checked_in_at)

walk_entry_profiles ​

Migration 0325 - walk entry profiles and quiz passes. SRC-1193. ## Vì sao migration này tồn tại Chủ dự án 03.10.2026: trước buổi NEMO WALK đầu tiên, learner khai một hồ sơ ngắn (số điện thoại, họ tên, ngày sinh, trình độ Speaking, trường hoặc nơi làm) rồi làm bài quiz 10 câu về luật chung, phải đúng 10/10 mới qua. Hệ thống phải GHI LẠI ở server rằng người này "đã qua bài kiểm tra để bắt đầu tham gia NEMO WALK" (learner, lúc qua, điểm, câu trả lời, phiên bản đề), để host và admin tin được con số ấy chứ không chỉ tin một cờ trong trình duyệt. walk_entry_profiles: một dòng mỗi learner, ghi dần từng bước của stepper (cột NULL = chưa trả lời), completed_at có giá trị khi đủ mọi bước bắt buộc. Số điện thoại và ngày sinh là dữ liệu cá nhân: chỉ chính learner và staff/admin đọc được (chốt ở workers/api/src/modules/walks/entry.ts). phone lưu dạng E.164 (+84...), dob dạng ISO YYYY-MM-DD; hiển thị DD.MM.YYYY là việc của UI. walk_quiz_attempts: mọi lượt nộp, kể cả lượt trượt (để biết câu nào hay sai mà sửa đề). Lượt qua là dòng passed = 1; mỗi learner nhiều nhất một lượt qua cho mỗi phiên bản đề (index UNIQUE có điều kiện), nên bấm nộp hai lần cùng lúc không đẻ hai dòng "đã qua". ## Chạy lại không đổi gì Chỉ CREATE TABLE IF NOT EXISTS và CREATE INDEX IF NOT EXISTS.

migration: 0325_walk_entry_profiles_and_quiz_passes.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id)
phoneTEXT—
full_nameTEXT—
dobTEXT—
speaking_answersTEXT—
speaking_levelTEXT—
speaking_confidenceTEXTCHECK (speaking_confidence IN ('low', 'medium'))
occupationTEXTCHECK (occupation IN ('student', 'working', 'other'))
school_nameTEXT—
school_gradeTEXT—
workplaceTEXT—
job_titleTEXT—
completed_atTEXT—
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

walk_quiz_attempts ​

migration: 0325_walk_entry_profiles_and_quiz_passes.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
quiz_versionTEXTNOT NULL
scoreINTEGERNOT NULL
totalINTEGERNOT NULL
answersTEXTNOT NULL
passedINTEGERNOT NULL DEFAULT 0 CHECK (passed IN (0, 1))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

Index: idx_walk_quiz_attempts_learner(learner_id, created_at) · idx_walk_quiz_one_pass(learner_id, quiz_version) UNIQUE

walk_registrations ​

Migration 0314 - walk registrations. SRC-1136, SDD-038 §146. ## Vì sao migration này tồn tại Chủ dự án 29.09.2026: NEMO WALK (15:00 Chủ nhật hằng tuần, miễn phí) cần đăng ký để biết bao nhiêu người đến và gửi thư xác nhận điểm hẹn. Mỗi learner đăng ký tối đa một lần mỗi tuần: khoá chính (walk_date, learner_id) chặn luôn hai lượt bấm cùng lúc. Lịch các tuần không nằm trong D1 mà ở workers/api/src/modules/walks/weeks.ts (bản sao của apps/learn/src/ielts/walk/weeks.json). Huỷ đăng ký là xoá dòng: không có tiền, không cần giữ vết. Cùng ngày, trước khi gộp: chỉ learner đã ghi danh NEMO SPEAK 1 (speak-2) hoặc NEMO SPEAK 2 (speak-3) mới đi WALK, mỗi lượt ghi danh cho 5 suất. course_id ghi suất của khoá nào đã được dùng; không có cột này thì người học cả hai khoá không đếm được còn bao nhiêu suất của từng khoá. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.

migration: 0314_walk_registrations.sql

CộtKiểuRàng buộc / ghi chú
walk_dateTEXTNOT NULL
learner_idTEXTNOT NULL REFERENCES learners(id)
course_idTEXTNOT NULL CHECK (course_id IN ('speak-2', 'speak-3'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (walk_date, learner_id)

Khóa ngoại: learner_id → learners

Index: idx_walk_registrations_learner(learner_id, course_id)