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

# Data Dictionary · Khác (phần 10/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). 33 bảng, `referral_rewards` tới `speak_rooms`.

## `referral_rewards`

Sổ quyền lợi. Mỗi lần ghi công sinh HAI dòng: một cho người giới thiệu, một cho người được giới thiệu — để hai bên đọc cùng một sự thật thay vì mỗi bên tính một kiểu.  amount_minor để NULL khi chưa biết tiền: phase 1 chưa có bảng giá (Q-014), nên dòng nằm ở trạng thái 'pending' cho tới khi có người thật sự trả tiền. KHÔNG bịa số 0 vào đây — 0 nghĩa là "đã tính ra và bằng không", khác hẳn "chưa tính".

migration: `0064_referrals.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `referred_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `referrer_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('referrer_credit','referee_discount')) |
| `percent` | INTEGER | NOT NULL,            -- chụp lại tỷ lệ TẠI THỜI ĐIỂM ghi công: đổi chính sách |
| `status` | TEXT | NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','earned','settled','void')) |
| `amount_minor` | INTEGER | ,                -- đơn vị nhỏ nhất (VND: đồng). NULL = chưa có tiền để tính |
| `currency` | TEXT | NOT NULL DEFAULT 'VND' |
| `note` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `settled_at` | TEXT | — |

**Khóa ngoại:** `referred_user_id` → `users` · `referrer_user_id` → `users`

**Index:** `idx_referral_rewards_referrer`(referrer_user_id, status) · `idx_referral_rewards_referred`(referred_user_id, status)

## `sat_answers`

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | NOT NULL REFERENCES sat_sessions(id) ON DELETE CASCADE |
| `item_id` | TEXT | NOT NULL REFERENCES sat_items(id) |
| `answer` | TEXT | — |
| `marked` | INTEGER | NOT NULL DEFAULT 0 CHECK (marked IN (0,1)) |
| `correct` | INTEGER | CHECK (correct IN (0,1)) |
| `answered_at` | TEXT | — |
| `cause` | TEXT | CHECK (cause IN ('knowledge','misread','pacing','careless')) |
| — | *table constraint* | `PRIMARY KEY (session_id, item_id)` |
| `revealed_at` | TEXT |  — thêm ở 0291_sat_practice_wave2.sql |

**Khóa ngoại:** `session_id` → `sat_sessions` · `item_id` → `sat_items`

**Index:** `idx_sat_answers_time`(answered_at)

## `sat_form_modules`

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `form_id` | TEXT | NOT NULL REFERENCES sat_forms(id) ON DELETE CASCADE |
| `section` | TEXT | NOT NULL CHECK (section IN ('rw','math')) |
| `stage` | TEXT | NOT NULL CHECK (stage IN ('m1','m2_easy','m2_hard')) |
| `position` | INTEGER | NOT NULL CHECK (position > 0) |
| `item_id` | TEXT | NOT NULL REFERENCES sat_items(id) |
| — | *table constraint* | `PRIMARY KEY (form_id, section, stage, position)` |

**Khóa ngoại:** `form_id` → `sat_forms` · `item_id` → `sat_items`

## `sat_forms`

Một đề đầy đủ: 6 module (mỗi phần M1, M2 dễ, M2 khó). `routing_json` giữ ngưỡng M1 → M2 khó.

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `title` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','retired')) |
| `routing_json` | TEXT | NOT NULL DEFAULT '{"rw":15,"math":13}' |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

## `sat_goals`

REQ-SAT-02. Một dòng mỗi learner.

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | PRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE |
| `school_group` | TEXT | CHECK (school_group IN ('vn','us_merit','us_top','custom')) |
| `target_total` | INTEGER | NOT NULL CHECK (target_total BETWEEN 400 AND 1600 AND target_total % 10 = 0) |
| `target_rw` | INTEGER | NOT NULL CHECK (target_rw BETWEEN 200 AND 800 AND target_rw % 10 = 0) |
| `target_math` | INTEGER | NOT NULL CHECK (target_math BETWEEN 200 AND 800 AND target_math % 10 = 0) |
| `test_date` | TEXT | — |
| `deadline` | TEXT | — |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

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

## `sat_items`

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `section` | TEXT | NOT NULL CHECK (section IN ('rw','math')) |
| `domain` | TEXT | NOT NULL CHECK (domain IN ( |
| `skill` | TEXT | NOT NULL DEFAULT '' |
| `node_id` | TEXT | — |
| `difficulty` | INTEGER | NOT NULL CHECK (difficulty BETWEEN 1 AND 3) |
| `passage_id` | TEXT | REFERENCES sat_passages(id) |
| `stem_md` | TEXT | NOT NULL |
| `format` | TEXT | NOT NULL CHECK (format IN ('mcq','spr')) |
| `choices_json` | TEXT | — |
| `answer_json` | TEXT | NOT NULL |
| `rationale_md` | TEXT | NOT NULL DEFAULT '' |
| `source` | TEXT | NOT NULL |
| `license` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','review','approved','retired')) |
| `author_id` | TEXT | — |
| `reviewer_id` | TEXT | — |
| `attempts_n` | INTEGER | NOT NULL DEFAULT 0 |
| `correct_n` | 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* | `CHECK (lower(source) NOT LIKE '%collegeboard%' AND lower(source) NOT LIKE '%college board%' AND lower(source) NOT LIKE '%bluebook%')` |
| — | *table constraint* | `CHECK (reviewer_id IS NULL OR author_id IS NULL OR reviewer_id <> author_id)` |
| — | *table constraint* | `CHECK (status <> 'approved' OR reviewer_id IS NOT NULL)` |
| — | *table constraint* | `CHECK ((format = 'mcq' AND choices_json IS NOT NULL) OR (format = 'spr' AND choices_json IS NULL))` |
| — | *table constraint* | `CHECK ((section = 'rw' AND domain IN ('craft_structure','information_ideas','conventions','expression_ideas'))` |

**Khóa ngoại:** `passage_id` → `sat_passages`

**Index:** `idx_sat_items_pick`(section, domain, status, difficulty)

## `sat_mistakes`

Đợt 2 (REQ-SAT-12). Tạo sẵn để đợt 2 không cần một migration chen giữa.

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `item_id` | TEXT | NOT NULL REFERENCES sat_items(id) |
| `cause` | TEXT | — |
| `due_at` | TEXT | — |
| `streak` | INTEGER | NOT NULL DEFAULT 0 |
| `cleared_at` | TEXT | — |
| — | *table constraint* | `PRIMARY KEY (learner_id, item_id)` |
| `last_correct_at` | TEXT |  — thêm ở 0291_sat_practice_wave2.sql |
| `created_at` | TEXT |  — thêm ở 0291_sat_practice_wave2.sql |

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

**Index:** `idx_sat_mistakes_due`(learner_id, cleared_at, due_at)

## `sat_passages`

Đoạn văn dùng chung cho một hoặc vài câu (R\&W). Math phần lớn không có đoạn.

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `section` | TEXT | NOT NULL CHECK (section IN ('rw','math')) |
| `body_md` | TEXT | NOT NULL |
| `source` | TEXT | NOT NULL |
| `license` | TEXT | NOT NULL |
| `created_by` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `CHECK (lower(source) NOT LIKE '%collegeboard%' AND lower(source) NOT LIKE '%college board%' AND lower(source) NOT LIKE '%bluebook%')` |

## `sat_plans`

Đợt 3 (REQ-SAT-15).

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `version` | INTEGER | NOT NULL |
| `plan_json` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (learner_id, version)` |

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

## `sat_session_items`

Migration 0291 — NEMO SAT đợt 2: phiên luyện và sổ lỗi. SRC-1024, SDD-044 §5.  Phiên luyện (luyện theo miền, module có giờ, ôn sổ lỗi) không lấy câu từ một đề đã ghép sẵn như bài đo, nên cần danh sách câu riêng của từng phiên. Sổ lỗi cần thêm mốc lần làm đúng gần nhất để thi hành luật "đúng hai lần cách nhau ít nhất 3 ngày thì ra khỏi sổ" (REQ-SAT-12). Idempotent: bảng mới dùng IF NOT EXISTS; cột mới thêm bằng ALTER, và ALTER chạy lại sẽ lỗi "duplicate column" nên migration này, như mọi migration, chỉ chạy đúng một lần qua d1_migrations.

migration: `0291_sat_practice_wave2.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | NOT NULL REFERENCES sat_sessions(id) ON DELETE CASCADE |
| `position` | INTEGER | NOT NULL CHECK (position > 0) |
| `item_id` | TEXT | NOT NULL REFERENCES sat_items(id) |
| — | *table constraint* | `PRIMARY KEY (session_id, position)` |

**Khóa ngoại:** `session_id` → `sat_sessions` · `item_id` → `sat_items`

**Index:** `idx_sat_session_items_item`(item_id)

## `sat_sessions`

Một phiên: bài đo (cha) và từng module của nó (con, `parent_id`), hoặc một phiên luyện lẻ.

migration: `0288_sat_practice_core.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `parent_id` | TEXT | REFERENCES sat_sessions(id) ON DELETE CASCADE |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('diagnostic','retest','domain','module','review')) |
| `form_id` | TEXT | REFERENCES sat_forms(id) |
| `section` | TEXT | CHECK (section IN ('rw','math')) |
| `stage` | TEXT | CHECK (stage IN ('m1','m2_easy','m2_hard')) |
| `started_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `deadline_at` | TEXT | — |
| `submitted_at` | TEXT | — |
| `result_json` | TEXT | — |

**Khóa ngoại:** `learner_id` → `learners` · `parent_id` → `sat_sessions` · `form_id` → `sat_forms`

**Index:** `idx_sat_sessions_learner`(learner_id, kind, started_at) · `idx_sat_sessions_parent`(parent_id)

## `school_achievements`

Thành tích gắn NĂM HỌC dạng "2024-2025" (chuỗi, không phải số): năm học Việt Nam vắt qua hai năm dương lịch; ép về số nguyên là buộc mọi chỗ đọc tự nhớ quy ước, và sớm muộn có chỗ nhớ khác.

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) |
| `school_year` | TEXT | NOT NULL |
| `category` | TEXT | NOT NULL CHECK (category IN ('hsg_quoc_gia','hsg_tinh','olympic_quoc_te','dai_hoc','khac')) |
| `title` | TEXT | NOT NULL |
| `detail` | TEXT | — |
| `quantity` | INTEGER | — |
| `source_url` | TEXT | — |
| `source_note` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'draft' 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_school_achievements_school`(school_id, school_year DESC)

## `school_code_redemptions`

migration: `0308_school_codes_and_outreach.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `code` | TEXT | NOT NULL REFERENCES school_codes(code) |
| `program` | TEXT | NOT NULL CHECK (program IN ('ielts', 'sat')) |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `user_id` | TEXT | ,                                -- người bấm nhập mã (learner hoặc bố mẹ) |
| `access_ends_on` | TEXT | NOT NULL |
| `redeemed_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

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

**Index:** `uq_school_redemption_program_learner`(program, learner_id) UNIQUE · `idx_school_redemptions_code`(code, program)

## `school_codes`

── Mã trường ─────────────────────────────────────────────────────────────────────────────────

migration: `0308_school_codes_and_outreach.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `code` | TEXT | PRIMARY KEY,                       -- chữ HOA, vd NEMO-7K3QX9 |
| `school_name` | TEXT | NOT NULL |
| `org_id` | TEXT | ,                                 -- outreach_orgs.id nếu trường đến từ outreach |
| `ielts_seats` | INTEGER | NOT NULL DEFAULT 100 CHECK (ielts_seats >= 0) |
| `sat_seats` | INTEGER | NOT NULL DEFAULT 100 CHECK (sat_seats >= 0) |
| `valid_from` | TEXT | NOT NULL DEFAULT '2026-10-01' |
| `valid_to` | TEXT | NOT NULL DEFAULT '2026-10-31' |
| `access_days` | INTEGER | NOT NULL DEFAULT 90 CHECK (access_days BETWEEN 1 AND 730) |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')) |
| `note` | TEXT | — |
| `created_by` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `revoked_at` | TEXT | — |
| `revoked_by` | TEXT | — |

**Index:** `idx_school_codes_org`(org_id)

## `school_students`

Người được nêu tên công khai của trường (Q-136) — KHÔNG phải sổ điểm danh học sinh đang học. Hai cổng chặn publish, cả hai thi hành trong service chứ không chỉ nhắc trên UI:   consent = 1                      (đã đồng ý được nêu tên)   verification_status = 'verified' (đã kiểm chứng, chỉ đạo chủ dự án 2026-08-17)

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) |
| `full_name` | TEXT | NOT NULL |
| `program_id` | TEXT | REFERENCES specialized_programs(id) |
| `program_label` | TEXT | — |
| `birth_year` | INTEGER | — |
| `cohort_year` | INTEGER | — |
| `grad_year` | INTEGER | — |
| `kind` | TEXT | NOT NULL DEFAULT 'alumni' CHECK (kind IN ('alumni','current','teacher')) |
| `highlight` | TEXT | — |
| `achievements` | TEXT | — |
| `current_org` | TEXT | — |
| `photo_media_id` | TEXT | REFERENCES media(id) |
| `mentor_profile_id` | TEXT | REFERENCES mentor_profiles(id) |
| `sources_json` | TEXT | NOT NULL DEFAULT '\[]' |
| `verification_status` | TEXT | NOT NULL DEFAULT 'unverified' |
| — | *table constraint* | `CHECK (verification_status IN ('unverified','verified','disputed'))` |
| `verification_note` | TEXT | ,   -- NỘI BỘ: đã đối chiếu gì, chỗ nào còn chưa chắc |
| `verified_at` | TEXT | — |
| `verified_by` | TEXT | REFERENCES users(id) |
| `consent` | INTEGER | NOT NULL DEFAULT 0 CHECK (consent IN (0,1)) |
| `consent_note` | TEXT | ,        -- NỘI BỘ |
| `featured` | INTEGER | NOT NULL DEFAULT 0 CHECK (featured IN (0,1)) |
| `status` | TEXT | NOT NULL DEFAULT 'draft' 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')) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `school_id` → `target_schools` · `program_id` → `specialized_programs` · `photo_media_id` → `media` · `mentor_profile_id` → `mentor_profiles` · `verified_by` → `users`

**Index:** `idx_school_students_school`(school_id, status, display_order) · `idx_school_students_mentor`(mentor_profile_id)

## `school_year_stats`

Số liệu tuyển sinh so sánh được. Không cột số nào NOT NULL: một trường có thể công bố điểm chuẩn mà không công bố số dự thi. Ô trống là câu trả lời hợp lệ; điền 0 là nói sai (SDD-020 §1.3).

migration: `0047_school_registry.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `school_id` | TEXT | NOT NULL REFERENCES target_schools(id) |
| `school_year` | TEXT | NOT NULL |
| `program_id` | TEXT | NOT NULL DEFAULT '' ,  -- '' = mức toàn trường; PK không nhận NULL |
| `applicants` | INTEGER | — |
| `seats` | INTEGER | — |
| `cutoff_score` | REAL | — |
| `ratio` | REAL | — |
| `source_url` | TEXT | — |
| `source_note` | TEXT | — |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (school_id, school_year, program_id)` |

**Khóa ngoại:** `school_id` → `target_schools`

## `slot_bookings`

Một dòng = một learner ghi tên vào MỘT buổi của MỘT ngày.  session_date là ngày lịch Việt Nam và phải rơi đúng vào `weekday` của khung — điều đó CSDL không kiểm được (không có biểu thức nào đọc sang bảng khác trong CHECK), nên `service.ts` kiểm, và có test cho đúng chỗ đó.  meeting_url chép lại vào đây lúc đăng ký (chứ không đọc sang mentor_slots lúc hiển thị): đổi link của khung vào tuần sau thì buổi tuần này vẫn giữ link mà learner đã nhận. Đây là chép có chủ ý của một giá trị tại thời điểm, không phải hai nguồn sự thật.

migration: `0223_mentor_slots.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `slot_id` | TEXT | NOT NULL REFERENCES mentor_slots(id) |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `session_date` | TEXT | NOT NULL,          -- YYYY-MM-DD giờ Việt Nam |
| `status` | TEXT | NOT NULL DEFAULT 'booked' CHECK (status IN ('booked','cancelled','attended','absent')) |
| `meeting_url` | TEXT | — |
| `booked_by_user_id` | TEXT | REFERENCES users(id) |
| `cancelled_at` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `slot_id` → `mentor_slots` · `learner_id` → `learners` · `booked_by_user_id` → `users`

**Index:** `idx_slot_booking_once`(slot_id, session_date, learner_id) UNIQUE · `idx_slot_booking_date`(session_date, slot_id) · `idx_slot_booking_learner`(learner_id, session_date DESC)

## `slot_preferences`

rank 1 = thích nhất, 2 = thích nhì. Khoá chính (learner_id, rank) ép "mỗi hạng đúng một khung"; UNIQUE (learner_id, slot_id) ép "không chọn cùng một khung cho cả hai hạng" — một learner khai cùng một khung ở cả hai chỗ là không nói thêm được gì, mà lại làm hỏng phép xếp lớp.

migration: `0223_mentor_slots.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `rank` | INTEGER | NOT NULL CHECK (rank IN (1,2)) |
| `slot_id` | TEXT | NOT NULL REFERENCES mentor_slots(id) |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (learner_id, rank)` |

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

**Index:** `idx_slot_pref_unique`(learner_id, slot_id) UNIQUE

## `speak_course_enrollments`

migration: `0312_speak_course_orders_and_enrollments.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `course_id` | TEXT | NOT NULL CHECK (course_id IN ('speak-2', 'speak-3')) |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `activation_code` | TEXT | NOT NULL UNIQUE |
| `first_session_index` | INTEGER | NOT NULL CHECK (first_session_index >= 0) |
| `activated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (course_id, learner_id)` |

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

## `speak_course_orders`

Migration 0312 - speak course orders and enrollments. SRC-1130, SRC-1131, SDD-038.  ## Vì sao migration này tồn tại  Chủ dự án 29.09.2026: NEMO SPEAK 2 cần trang đăng ký + thanh toán riêng, và đăng ký xong thì hệ thống gửi MÃ KÍCH HOẠT để vào lớp. Tới bản này repo không có chỗ nào giữ "ai đã đặt khoá nào, chuyển khoản với nội dung gì, đã trả chưa", cũng không có sổ ghi danh một khoá có ngày khai giảng. Không có sổ đơn thì admin đối chiếu sao kê bằng trí nhớ; không có sổ ghi danh thì mã kích hoạt không mở ra được thứ gì.  Hai bảng tách nhau vì NGƯỜI TRẢ TIỀN và NGƯỜI HỌC có thể khác nhau: bố mẹ đặt đơn trên hồ sơ của mình rồi đưa mã cho con nhập. Đơn thuộc learner đặt; ghi danh thuộc learner nhập mã.  SRC-1131 (cùng ngày, trước khi gộp): khoá chạy CUỐN CHIẾU, learner vào lúc nào cũng được và học 5 buổi kế tiếp. `first_session_index` giữ chỉ số buổi đầu của người ấy trong dòng buổi liên tục của khoá (xem workers/api/src/modules/speakCourses/cohort.ts), nên 5 ngày của họ cố định kể cả khi lịch chung chạy tiếp. Thêm khoá `speak-3`.  ## Chạy lại không đổi gì  `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`.

migration: `0312_speak_course_orders_and_enrollments.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `course_id` | TEXT | NOT NULL CHECK (course_id IN ('speak-2', 'speak-3')) |
| `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_speak_course_orders_learner`(learner_id, course_id) · `idx_speak_course_orders_status`(status, created_at)

## `speak_pair_observers`

Người vào sau hai người đầu: xem cùng bài, không được cộng giờ.

migration: `0302_speak_pair_sessions.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `session_id` | TEXT | NOT NULL REFERENCES speak_pair_sessions(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `joined_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:** `session_id` → `speak_pair_sessions` · `learner_id` → `learners`

## `speak_pair_sessions`

Migration 0302 - Buổi luyện nói theo cặp ("Speak together"). SRC-1078.  Chủ dự án 27.09.2026: vài learner học cùng nhau qua Zoom, mở cùng một bài nói, đóng vai A (hỏi) và B (trả lời) qua bốn lượt rồi chia sẻ; hệ thống phải ghi nhận "A và B đã luyện xong bài này" và cộng thời gian học cho CẢ HAI.  ## Vì sao cần một bảng  Hai learner ở hai máy khác nhau. Muốn biết "cả hai cùng xong một bài" thì phải có một chỗ chung cho hai máy cùng trỏ vào: một buổi, có mã 4 số (một người tạo, đọc mã qua Zoom, người kia nhập), ghi ai là chủ, ai tham gia, đang ở bước nào, và mỗi người bấm Xong lúc nào. Phút học vẫn ghi vào `learning_events` như mọi hoạt động khác (skill 'speaking', ref 'pair:\<bài>'), nên tổng giờ, chuỗi ngày, bảng xếp hạng và thưởng tự cộng; bảng này chỉ giữ trạng thái của buổi.  ## Bản 2 (chủ dự án 27.09.2026, cùng ngày, TRƯỚC khi migration này từng được áp)  Sửa thẳng file này thay cho thêm migration mới vì nó chưa chạm môi trường nào: Actions bị chặn từ trước khi nó lên main. Bốn luật mới:   · chỉ ĐÚNG HAI người được cộng giờ; người vào sau là quan sát viên (`speak_pair_observers`);   · vào nhầm phòng thì ra được (`ended_at` khi chủ buổi rời, hoặc trả chỗ khi người kia rời);   · giờ chỉ bắt đầu khi CẢ HAI đã xem bài và bấm Bắt đầu (`host_ready_at`, `guest_ready_at`,     `started_at`); bước 0 là bước xem bài;   · ngưỡng 8 phút đo từ `started_at`, không từ lúc tạo mã.  ## Chạy lại không đổi gì  CREATE TABLE / INDEX IF NOT EXISTS.

migration: `0302_speak_pair_sessions.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `code` | TEXT | NOT NULL |
| `topic` | TEXT | NOT NULL |
| `host_learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `guest_learner_id` | TEXT | REFERENCES learners(id) ON DELETE CASCADE |
| `step` | INTEGER | NOT NULL DEFAULT 0 CHECK (step BETWEEN 0 AND 5) |
| `host_ready_at` | TEXT | — |
| `guest_ready_at` | TEXT | — |
| `started_at` | TEXT | — |
| `ended_at` | TEXT | — |
| `host_done_at` | TEXT | — |
| `guest_done_at` | TEXT | — |
| `credited_at` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `host_learner_id` → `learners` · `guest_learner_id` → `learners`

**Index:** `idx_speak_pair_open_code`(code) · `idx_speak_pair_host`(host_learner_id, created_at DESC) · `idx_speak_pair_guest`(guest_learner_id, created_at DESC)

## `speak_recording_comments`

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `recording_id` | TEXT | NOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE |
| `author_user_id` | TEXT | NOT NULL |
| `at_ms` | INTEGER | — |
| `body` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `recording_id` → `speak_recordings`

**Index:** `idx_speak_recording_comments_rec`(recording_id, created_at)

## `speak_recording_deletions`

Audit of every deletion (by a participant, by expiry, by admin). Kept after the file is gone.

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `recording_id` | TEXT | NOT NULL |
| `reason` | TEXT | NOT NULL CHECK (reason IN ('participant','expired','admin')) |
| `learner_id` | TEXT | — |
| `actor_user_id` | TEXT | — |
| `deleted_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Index:** `idx_speak_recording_deletions_rec`(recording_id)

## `speak_recording_feedback`

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `recording_id` | TEXT | NOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `criterion` | TEXT | NOT NULL CHECK (criterion IN ('fc','lr','gra','p')) |
| `band` | REAL | — |
| `comment` | TEXT | NOT NULL |
| `source` | TEXT | NOT NULL DEFAULT 'ai' CHECK (source IN ('ai','mentor')) |
| `model` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

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

**Index:** `idx_speak_recording_feedback_rec`(recording_id, learner_id)

## `speak_recording_participants`

Whose voice is on the recording: everyone present at any moment while it was recording.

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `recording_id` | TEXT | NOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| — | *table constraint* | `PRIMARY KEY (recording_id, learner_id)` |

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

**Index:** `idx_speak_recording_participants_learner`(learner_id)

## `speak_recording_parts`

migration: `0319_speak_room_host_and_segments.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `recording_id` | TEXT | NOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE |
| `seq` | INTEGER | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'recording' CHECK (status IN ('recording','processing','ready','failed')) |
| `provider_recording_id` | TEXT | — |
| `content_type` | TEXT | — |
| `bytes` | INTEGER | — |
| `started_at` | TEXT | NOT NULL |
| `stopped_at` | TEXT | — |
| `duration_seconds` | INTEGER | — |
| — | *table constraint* | `UNIQUE (recording_id, seq)` |

**Khóa ngoại:** `recording_id` → `speak_recordings`

**Index:** `idx_speak_recording_parts_provider`(provider_recording_id)

## `speak_recording_segments`

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `recording_id` | TEXT | NOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `start_ms` | INTEGER | NOT NULL |
| `end_ms` | INTEGER | NOT NULL |
| `text` | TEXT | NOT NULL |
| `model` | TEXT | — |

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

**Index:** `idx_speak_recording_segments_rec`(recording_id, start_ms)

## `speak_recording_tracks`

One file per speaker (RealtimeKit track recording), the input for speaker-split transcripts.

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `recording_id` | TEXT | NOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `offset_ms` | INTEGER | NOT NULL DEFAULT 0 |
| `duration_seconds` | INTEGER | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

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

**Index:** `idx_speak_recording_tracks_rec`(recording_id)

## `speak_recordings`

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `room_id` | TEXT | NOT NULL REFERENCES speak_rooms(id) ON DELETE CASCADE |
| `status` | TEXT | NOT NULL DEFAULT 'scheduled' |
| — | *table constraint* | `CHECK (status IN ('scheduled','recording','processing','ready','skipped','failed','deleted'))` |
| `skip_reason` | TEXT | — |
| `planned_start_at` | TEXT | NOT NULL |
| `planned_end_at` | TEXT | NOT NULL |
| `provider_recording_id` | TEXT | — |
| `content_type` | TEXT | — |
| `bytes` | INTEGER | — |
| `started_at` | TEXT | — |
| `stopped_at` | TEXT | — |
| `duration_seconds` | INTEGER | — |
| `expires_at` | TEXT | — |
| `deleted_at` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `room_id` → `speak_rooms`

**Index:** `idx_speak_recordings_room`(room_id) UNIQUE · `idx_speak_recordings_status`(status, planned_end_at) · `idx_speak_recordings_expiry`(expires_at) · `idx_speak_recordings_provider`(provider_recording_id)

## `speak_room_events`

migration: `0319_speak_room_host_and_segments.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `room_id` | TEXT | NOT NULL REFERENCES speak_rooms(id) ON DELETE CASCADE |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('host_transfer','host_auto_pass','record','pause','resume','stop','auto_stop','room_end')) |
| `actor_learner_id` | TEXT | — |
| `target_learner_id` | TEXT | — |
| `detail` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |

**Khóa ngoại:** `room_id` → `speak_rooms`

**Index:** `idx_speak_room_events_room`(room_id, created_at)

## `speak_room_participants`

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `room_id` | TEXT | NOT NULL REFERENCES speak_rooms(id) ON DELETE CASCADE |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `joined_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `consent_at` | TEXT | — |
| `left_at` | TEXT | — |
| `seen_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY KEY (room_id, learner_id)` |

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

**Index:** `idx_speak_room_participants_learner`(learner_id)

## `speak_rooms`

Migration 0318 - speak rooms and recordings. SRC-1162, SDD-052.  ## Why this migration exists  Owner request 30.09.2026: pair talk moves from Zoom breakout rooms into voice rooms on learn.nemo12.com/speak. A room is joined by a short code; everyone present is recorded automatically from +30 s after the room starts, for 10 minutes; everyone who was present can listen back; audio lives in R2 and is deleted after 90 days.  Access to a recording is derived from `speak_recording_participants` (who was present, i.e. whose voice is on the file), never from the room creator alone. Without that table a learner who left before the recording started could still play it, and a parent could not be matched to the child whose voice is on it.  Phase 2 tables (transcripts per speaker, AI feedback per IELTS criterion, mentor comments) are created now but no code writes them yet (SDD-052 §9). Creating them now keeps phase 2 a code change, not a schema change on a table that already holds children's recordings.  ## Re-running changes nothing  CREATE TABLE / INDEX IF NOT EXISTS only.

migration: `0318_speak_rooms_and_recordings.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `code` | TEXT | NOT NULL |
| `created_by` | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
| `context_kind` | TEXT | NOT NULL DEFAULT 'none' CHECK (context_kind IN ('none','session','topic')) |
| `context_ref` | TEXT | — |
| `status` | TEXT | NOT NULL DEFAULT 'open' CHECK (status IN ('open','live','ended')) |
| `provider` | TEXT | NOT NULL DEFAULT 'mock' |
| `provider_meeting_id` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `started_at` | TEXT | — |
| `ended_at` | TEXT | — |
| `host_learner_id` | TEXT | REFERENCES learners(id) ON DELETE SET NULL — thêm ở 0319_speak_room_host_and_segments.sql |

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

**Index:** `idx_speak_rooms_code`(code, status) · `idx_speak_rooms_status`(status, created_at)
