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

# 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](../data-dictionary.md). 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | NOT NULL |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | NOT NULL |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `school_id` | TEXT | NOT NULL REFERENCES target_schools(id) |
| `subject_code` | TEXT | NOT NULL |
| `name_vi` | TEXT | NOT NULL |
| `intake_size` | INTEGER | — |
| `notes` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'published' CHECK (status IN ('draft','published','archived')) |
| `display_order` | INTEGER | NOT NULL DEFAULT 100 |
| `created_at` | TEXT | NOT 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](../../architecture/sdd-019-mentor-albums.md)).  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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `author_name` | TEXT | NOT NULL |
| `author_role` | TEXT | — |
| `photo_media_id` | TEXT | REFERENCES media(id) |
| `quote` | TEXT | NOT NULL |
| `body` | TEXT | — |
| `program_code` | TEXT | — |
| `mentor_id` | TEXT | REFERENCES mentor_profiles(id) |
| `audience` | TEXT | NOT NULL DEFAULT 'both' CHECK (audience IN ('both','parent','student')) |
| `consent` | INTEGER | NOT NULL DEFAULT 0 CHECK (consent IN (0,1)) |
| `consent_note` | TEXT | — |
| `verified` | INTEGER | NOT NULL DEFAULT 0 CHECK (verified IN (0,1)) |
| `verified_at` | TEXT | — |
| `verified_by` | TEXT | REFERENCES users(id) |
| `internal_note` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')) |
| `featured` | INTEGER | NOT NULL DEFAULT 0 CHECK (featured IN (0,1)) |
| `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')) |
| `said_on` | TEXT |  — thêm ở 0175_testimonial_card_fields.sql |
| `facts_json` | TEXT | NOT NULL DEFAULT '\[]' — thêm ở 0175_testimonial_card_fields.sql |
| `is_sample` | INTEGER | NOT NULL DEFAULT 0 — thêm ở 0175_testimonial_card_fields.sql |
| `kind` | TEXT | NOT NULL DEFAULT 'quote' — thêm ở 0199_testimonial_stories.sql |
| `slug` | TEXT |  — thêm ở 0199_testimonial_stories.sql |
| `title` | TEXT |  — thêm ở 0199_testimonial_stories.sql |
| `summary` | TEXT |  — thêm ở 0199_testimonial_stories.sql |
| `cover_media_id` | TEXT | REFERENCES media(id) — thêm ở 0199_testimonial_stories.sql |
| `content_state` | TEXT | NOT NULL DEFAULT 'draft' — thêm ở 0199_testimonial_stories.sql |
| `visibility` | TEXT | NOT NULL DEFAULT 'private' — thêm ở 0199_testimonial_stories.sql |
| `placements_json` | TEXT | NOT NULL DEFAULT '\[]' — thêm ở 0199_testimonial_stories.sql |
| `source_note_id` | TEXT | REFERENCES 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 &lt;token&gt;` 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `token_hash` | TEXT | PRIMARY KEY |
| `agent_id` | TEXT | NOT NULL REFERENCES tp_agents(id) |
| `user_id` | TEXT | REFERENCES users(id),          -- NULL = token không thay mặt ai (job nền, research) |
| `label` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `last_used_at` | TEXT | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `agent_id` | TEXT | NOT NULL REFERENCES tp_agents(id) |
| `tool_id` | TEXT | NOT NULL |
| `resource_scope` | TEXT | NOT NULL DEFAULT 'user',      -- '*' | 'user' | 'learner:L1,learner:L2' | 'family:F1' |
| `environment_scope` | TEXT | NOT NULL DEFAULT '*',      -- '\*' | 'production' | 'staging,development' |
| `permission` | TEXT | NOT NULL DEFAULT 'read' CHECK (permission IN ('read','write')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `name` | TEXT | NOT NULL |
| `description` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended','retired')) |
| `token_hash` | TEXT | UNIQUE |
| `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')) |

## `tp_applications`

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

migration: `0188_tool_plane.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `name` | TEXT | NOT NULL |
| `domain` | TEXT | — |
| `repository` | TEXT | — |
| `repository_path` | TEXT | — |
| `environment` | TEXT | NOT NULL DEFAULT 'production' |
| `owner` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'live' CHECK (status IN ('planned','live','deprecated')) |
| `description` | TEXT | — |
| `architecture_reference` | TEXT | — |
| `database` | TEXT | — |
| `deployment_target` | TEXT | — |
| `metadata` | 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')) |

## `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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `request_id` | TEXT | NOT NULL |
| `timestamp` | TEXT | NOT NULL |
| `agent_id` | TEXT | NOT NULL |
| `session_id` | TEXT | NOT NULL |
| `user_id` | TEXT | — |
| `tool_id` | TEXT | NOT NULL |
| `resource` | TEXT | — |
| `decision` | TEXT | NOT NULL CHECK (decision IN ('allow','deny','require_approval')) |
| `status` | TEXT | NOT NULL CHECK (status IN ('success','error','denied')) |
| `error_code` | TEXT | — |
| `duration_ms` | INTEGER | NOT NULL DEFAULT 0 |
| `environment` | TEXT | NOT NULL |
| `metadata` | TEXT | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `name` | TEXT | NOT NULL |
| `effect` | TEXT | NOT NULL CHECK (effect IN ('allow','deny','require_approval')) |
| `agent_scope` | TEXT | NOT NULL DEFAULT '*' |
| `tool_scope` | TEXT | NOT NULL DEFAULT '*' |
| `resource_scope` | TEXT | NOT NULL DEFAULT '*' |
| `environment_scope` | TEXT | NOT NULL DEFAULT '*' |
| `conditions` | TEXT | ,                                  -- JSON: {"risk_at_least":"HIGH"} | {"mode":"write"} |
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY,                     -- learner.get_profile:v1 |
| `name` | TEXT | NOT NULL,                      -- learner.get_profile |
| `description` | TEXT | — |
| `category` | TEXT | NOT NULL |
| `version` | INTEGER | NOT NULL DEFAULT 1 |
| `risk_level` | TEXT | NOT NULL CHECK (risk_level IN ('LOW','MEDIUM','HIGH','CRITICAL')) |
| `mode` | TEXT | NOT NULL CHECK (mode IN ('read','write')) |
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
| `input_schema` | TEXT | — |
| `output_schema` | 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')) |

## `tutor_digest_items`

migration: `0321_tutor_learner_profile_and_weekly_digest.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `family_listing_id` | TEXT | NOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE |
| `tutor_listing_id` | TEXT | NOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE |
| `sent_at` | TEXT | NOT NULL |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `from_listing_id` | TEXT | NOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE |
| `to_listing_id` | TEXT | NOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE |
| `from_user_id` | TEXT | NOT NULL,       -- để đếm trần 10 lượt/ngày theo người, không theo listing |
| `reasons` | TEXT | NOT NULL,            -- JSON mảng mã lý do |
| `message` | TEXT | ,                     -- tối đa 300 ký tự, đã lọc số điện thoại, email, URL |
| `status` | TEXT | NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `responded_at` | TEXT | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `listing_id` | TEXT | PRIMARY KEY REFERENCES tutor_listings(id) ON DELETE CASCADE |
| `contact_name` | TEXT | NOT NULL |
| `phone` | TEXT | NOT NULL |
| `email` | TEXT | NOT NULL |
| `learner_name` | TEXT |  — thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql |
| `school` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `side` | TEXT | NOT NULL CHECK (side IN ('family', 'tutor')) |
| `public_no` | INTEGER | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'hidden', 'paused')) |
| `goals` | TEXT | NOT NULL,              -- JSON mảng mã mục tiêu: ielts, sat, chuyen, university, subject, other |
| `subjects` | TEXT | NOT NULL,           -- môn / phần cần giúp (gia đình) hoặc môn dạy (gia sư) |
| `area` | TEXT | NOT NULL,               -- quận Hà Nội hoặc 'online' |
| `online` | INTEGER | NOT NULL DEFAULT 0,-- nhận học online |
| `schedule` | TEXT | NOT NULL,           -- lịch mong muốn (gia đình) hoặc lịch rảnh (gia sư) |
| `note` | TEXT | ,                        -- ghi chú ngắn (gia đình) hoặc giới thiệu ngắn (gia sư) |
| `family_role` | TEXT | CHECK (family_role IS NULL OR family_role IN ('student', 'parent')) |
| `grade` | TEXT | — |
| `level` | TEXT | — |
| `evidence` | TEXT | ,                    -- gia sư: mỗi dòng một bằng chứng |
| `experience` | 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')) |
| `approved_at` | TEXT | — |
| `birth_year` | INTEGER |  — thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql |
| `target` | TEXT |  — thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql |
| `exam_month` | TEXT |  — thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql |
| `sessions_per_week` | INTEGER |  — thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql |
| `budget` | TEXT |  — thêm ở 0321_tutor_learner_profile_and_weekly_digest.sql |
| `closed_at` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `family_listing_id` | TEXT | NOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE |
| `tutor_listing_id` | TEXT | NOT NULL REFERENCES tutor_listings(id) ON DELETE CASCADE |
| `status` | TEXT | NOT NULL DEFAULT 'matched' CHECK (status IN ('matched', 'connected')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `connected_at` | TEXT | — |
| `connected_by` | TEXT | — |

**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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `subject_id` | TEXT | NOT NULL |
| `unit_key` | TEXT | NOT NULL,          -- 'strand|module|unit', khớp unitKeyOf() ở apps/learn |
| `prereq_key` | TEXT | NOT NULL |
| `rank` | INTEGER | NOT NULL DEFAULT 1,   -- 1 = prerequisite gần nhất, 2 = cái thứ hai |
| `rationale_vi` | TEXT | ,                   -- một câu: vì sao unit kia là prerequisite của unit này |
| `status` | TEXT | NOT NULL DEFAULT 'auto', -- auto | reviewed | edited | rejected |
| `source` | TEXT | NOT NULL DEFAULT 'ai-claude' |
| `reviewed_by` | TEXT | — |
| `reviewed_at` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `program` | TEXT | NOT NULL |
| `first_seen_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `last_seen_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `views` | INTEGER | NOT NULL DEFAULT 0 |
| `submits` | INTEGER | NOT NULL DEFAULT 0 |
| `signup_entry` | INTEGER | NOT NULL DEFAULT 0 |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `walk_date` | TEXT | NOT NULL |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `checked_in_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `partner_id` | TEXT | REFERENCES learners(id) |
| `paired_by` | TEXT | CHECK (paired_by IN ('auto', 'host')) |
| `paired_at` | TEXT | — |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | PRIMARY KEY REFERENCES learners(id) |
| `phone` | TEXT | — |
| `full_name` | TEXT | — |
| `dob` | TEXT | — |
| `speaking_answers` | TEXT | — |
| `speaking_level` | TEXT | — |
| `speaking_confidence` | TEXT | CHECK (speaking_confidence IN ('low', 'medium')) |
| `occupation` | TEXT | CHECK (occupation IN ('student', 'working', 'other')) |
| `school_name` | TEXT | — |
| `school_grade` | TEXT | — |
| `workplace` | TEXT | — |
| `job_title` | TEXT | — |
| `completed_at` | 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')) |

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

## `walk_quiz_attempts`

migration: `0325_walk_entry_profiles_and_quiz_passes.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `quiz_version` | TEXT | NOT NULL |
| `score` | INTEGER | NOT NULL |
| `total` | INTEGER | NOT NULL |
| `answers` | TEXT | NOT NULL |
| `passed` | INTEGER | NOT NULL DEFAULT 0 CHECK (passed IN (0, 1)) |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `walk_date` | TEXT | NOT NULL |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `course_id` | TEXT | NOT NULL CHECK (course_id IN ('speak-2', 'speak-3')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (walk_date, learner_id)` |

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

**Index:** `idx_walk_registrations_learner`(learner_id, course_id)
