Skip to content

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. 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
referred_user_idTEXTNOT NULL REFERENCES users(id)
referrer_user_idTEXTNOT NULL REFERENCES users(id)
kindTEXTNOT NULL CHECK (kind IN ('referrer_credit','referee_discount'))
percentINTEGERNOT NULL, -- chụp lại tỷ lệ TẠI THỜI ĐIỂM ghi công: đổi chính sách
statusTEXTNOT NULL DEFAULT 'pending' CHECK (status IN ('pending','earned','settled','void'))
amount_minorINTEGER, -- đơn vị nhỏ nhất (VND: đồng). NULL = chưa có tiền để tính
currencyTEXTNOT NULL DEFAULT 'VND'
noteTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
settled_atTEXT—

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ộtKiểuRàng buộc / ghi chú
session_idTEXTNOT NULL REFERENCES sat_sessions(id) ON DELETE CASCADE
item_idTEXTNOT NULL REFERENCES sat_items(id)
answerTEXT—
markedINTEGERNOT NULL DEFAULT 0 CHECK (marked IN (0,1))
correctINTEGERCHECK (correct IN (0,1))
answered_atTEXT—
causeTEXTCHECK (cause IN ('knowledge','misread','pacing','careless'))
—table constraintPRIMARY KEY (session_id, item_id)
revealed_atTEXT— 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ộtKiểuRàng buộc / ghi chú
form_idTEXTNOT NULL REFERENCES sat_forms(id) ON DELETE CASCADE
sectionTEXTNOT NULL CHECK (section IN ('rw','math'))
stageTEXTNOT NULL CHECK (stage IN ('m1','m2_easy','m2_hard'))
positionINTEGERNOT NULL CHECK (position > 0)
item_idTEXTNOT NULL REFERENCES sat_items(id)
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
titleTEXTNOT NULL
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','retired'))
routing_jsonTEXTNOT NULL DEFAULT '{"rw":15,"math":13}'
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE
school_groupTEXTCHECK (school_group IN ('vn','us_merit','us_top','custom'))
target_totalINTEGERNOT NULL CHECK (target_total BETWEEN 400 AND 1600 AND target_total % 10 = 0)
target_rwINTEGERNOT NULL CHECK (target_rw BETWEEN 200 AND 800 AND target_rw % 10 = 0)
target_mathINTEGERNOT NULL CHECK (target_math BETWEEN 200 AND 800 AND target_math % 10 = 0)
test_dateTEXT—
deadlineTEXT—
updated_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
sectionTEXTNOT NULL CHECK (section IN ('rw','math'))
domainTEXTNOT NULL CHECK (domain IN (
skillTEXTNOT NULL DEFAULT ''
node_idTEXT—
difficultyINTEGERNOT NULL CHECK (difficulty BETWEEN 1 AND 3)
passage_idTEXTREFERENCES sat_passages(id)
stem_mdTEXTNOT NULL
formatTEXTNOT NULL CHECK (format IN ('mcq','spr'))
choices_jsonTEXT—
answer_jsonTEXTNOT NULL
rationale_mdTEXTNOT NULL DEFAULT ''
sourceTEXTNOT NULL
licenseTEXTNOT NULL
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft','review','approved','retired'))
author_idTEXT—
reviewer_idTEXT—
attempts_nINTEGERNOT NULL DEFAULT 0
correct_nINTEGERNOT 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 constraintCHECK (lower(source) NOT LIKE '%collegeboard%' AND lower(source) NOT LIKE '%college board%' AND lower(source) NOT LIKE '%bluebook%')
—table constraintCHECK (reviewer_id IS NULL OR author_id IS NULL OR reviewer_id <> author_id)
—table constraintCHECK (status <> 'approved' OR reviewer_id IS NOT NULL)
—table constraintCHECK ((format = 'mcq' AND choices_json IS NOT NULL) OR (format = 'spr' AND choices_json IS NULL))
—table constraintCHECK ((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ộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
item_idTEXTNOT NULL REFERENCES sat_items(id)
causeTEXT—
due_atTEXT—
streakINTEGERNOT NULL DEFAULT 0
cleared_atTEXT—
—table constraintPRIMARY KEY (learner_id, item_id)
last_correct_atTEXT— thêm ở 0291_sat_practice_wave2.sql
created_atTEXT— 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
sectionTEXTNOT NULL CHECK (section IN ('rw','math'))
body_mdTEXTNOT NULL
sourceTEXTNOT NULL
licenseTEXTNOT NULL
created_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintCHECK (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ộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
versionINTEGERNOT NULL
plan_jsonTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
session_idTEXTNOT NULL REFERENCES sat_sessions(id) ON DELETE CASCADE
positionINTEGERNOT NULL CHECK (position > 0)
item_idTEXTNOT NULL REFERENCES sat_items(id)
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
parent_idTEXTREFERENCES sat_sessions(id) ON DELETE CASCADE
kindTEXTNOT NULL CHECK (kind IN ('diagnostic','retest','domain','module','review'))
form_idTEXTREFERENCES sat_forms(id)
sectionTEXTCHECK (section IN ('rw','math'))
stageTEXTCHECK (stage IN ('m1','m2_easy','m2_hard'))
started_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
deadline_atTEXT—
submitted_atTEXT—
result_jsonTEXT—

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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
school_idTEXTNOT NULL REFERENCES target_schools(id)
school_yearTEXTNOT NULL
categoryTEXTNOT NULL CHECK (category IN ('hsg_quoc_gia','hsg_tinh','olympic_quoc_te','dai_hoc','khac'))
titleTEXTNOT NULL
detailTEXT—
quantityINTEGER—
source_urlTEXT—
source_noteTEXT—
statusTEXTNOT NULL DEFAULT 'draft' 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_school_achievements_school(school_id, school_year DESC)

school_code_redemptions ​

migration: 0308_school_codes_and_outreach.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
codeTEXTNOT NULL REFERENCES school_codes(code)
programTEXTNOT NULL CHECK (program IN ('ielts', 'sat'))
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
user_idTEXT, -- người bấm nhập mã (learner hoặc bố mẹ)
access_ends_onTEXTNOT NULL
redeemed_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
codeTEXTPRIMARY KEY, -- chữ HOA, vd NEMO-7K3QX9
school_nameTEXTNOT NULL
org_idTEXT, -- outreach_orgs.id nếu trường đến từ outreach
ielts_seatsINTEGERNOT NULL DEFAULT 100 CHECK (ielts_seats >= 0)
sat_seatsINTEGERNOT NULL DEFAULT 100 CHECK (sat_seats >= 0)
valid_fromTEXTNOT NULL DEFAULT '2026-10-01'
valid_toTEXTNOT NULL DEFAULT '2026-10-31'
access_daysINTEGERNOT NULL DEFAULT 90 CHECK (access_days BETWEEN 1 AND 730)
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked'))
noteTEXT—
created_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
revoked_atTEXT—
revoked_byTEXT—

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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
school_idTEXTNOT NULL REFERENCES target_schools(id)
full_nameTEXTNOT NULL
program_idTEXTREFERENCES specialized_programs(id)
program_labelTEXT—
birth_yearINTEGER—
cohort_yearINTEGER—
grad_yearINTEGER—
kindTEXTNOT NULL DEFAULT 'alumni' CHECK (kind IN ('alumni','current','teacher'))
highlightTEXT—
achievementsTEXT—
current_orgTEXT—
photo_media_idTEXTREFERENCES media(id)
mentor_profile_idTEXTREFERENCES mentor_profiles(id)
sources_jsonTEXTNOT NULL DEFAULT '[]'
verification_statusTEXTNOT NULL DEFAULT 'unverified'
—table constraintCHECK (verification_status IN ('unverified','verified','disputed'))
verification_noteTEXT, -- NỘI BỘ: đã đối chiếu gì, chỗ nào còn chưa chắc
verified_atTEXT—
verified_byTEXTREFERENCES users(id)
consentINTEGERNOT NULL DEFAULT 0 CHECK (consent IN (0,1))
consent_noteTEXT, -- NỘI BỘ
featuredINTEGERNOT NULL DEFAULT 0 CHECK (featured IN (0,1))
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','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'))

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ộtKiểuRàng buộc / ghi chú
school_idTEXTNOT NULL REFERENCES target_schools(id)
school_yearTEXTNOT NULL
program_idTEXTNOT NULL DEFAULT '' , -- '' = mức toàn trường; PK không nhận NULL
applicantsINTEGER—
seatsINTEGER—
cutoff_scoreREAL—
ratioREAL—
source_urlTEXT—
source_noteTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
slot_idTEXTNOT NULL REFERENCES mentor_slots(id)
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
session_dateTEXTNOT NULL, -- YYYY-MM-DD giờ Việt Nam
statusTEXTNOT NULL DEFAULT 'booked' CHECK (status IN ('booked','cancelled','attended','absent'))
meeting_urlTEXT—
booked_by_user_idTEXTREFERENCES users(id)
cancelled_atTEXT—
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
rankINTEGERNOT NULL CHECK (rank IN (1,2))
slot_idTEXTNOT NULL REFERENCES mentor_slots(id)
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
course_idTEXTNOT NULL CHECK (course_id IN ('speak-2', 'speak-3'))
learner_idTEXTNOT NULL REFERENCES learners(id)
activation_codeTEXTNOT NULL UNIQUE
first_session_indexINTEGERNOT NULL CHECK (first_session_index >= 0)
activated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
course_idTEXTNOT NULL CHECK (course_id IN ('speak-2', 'speak-3'))
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_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ộtKiểuRàng buộc / ghi chú
session_idTEXTNOT NULL REFERENCES speak_pair_sessions(id) ON DELETE CASCADE
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
joined_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
codeTEXTNOT NULL
topicTEXTNOT NULL
host_learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
guest_learner_idTEXTREFERENCES learners(id) ON DELETE CASCADE
stepINTEGERNOT NULL DEFAULT 0 CHECK (step BETWEEN 0 AND 5)
host_ready_atTEXT—
guest_ready_atTEXT—
started_atTEXT—
ended_atTEXT—
host_done_atTEXT—
guest_done_atTEXT—
credited_atTEXT—
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
recording_idTEXTNOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE
author_user_idTEXTNOT NULL
at_msINTEGER—
bodyTEXTNOT NULL
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
recording_idTEXTNOT NULL
reasonTEXTNOT NULL CHECK (reason IN ('participant','expired','admin'))
learner_idTEXT—
actor_user_idTEXT—
deleted_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
recording_idTEXTNOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
criterionTEXTNOT NULL CHECK (criterion IN ('fc','lr','gra','p'))
bandREAL—
commentTEXTNOT NULL
sourceTEXTNOT NULL DEFAULT 'ai' CHECK (source IN ('ai','mentor'))
modelTEXT—
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
recording_idTEXTNOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
recording_idTEXTNOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE
seqINTEGERNOT NULL
statusTEXTNOT NULL DEFAULT 'recording' CHECK (status IN ('recording','processing','ready','failed'))
provider_recording_idTEXT—
content_typeTEXT—
bytesINTEGER—
started_atTEXTNOT NULL
stopped_atTEXT—
duration_secondsINTEGER—
—table constraintUNIQUE (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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
recording_idTEXTNOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
start_msINTEGERNOT NULL
end_msINTEGERNOT NULL
textTEXTNOT NULL
modelTEXT—

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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
recording_idTEXTNOT NULL REFERENCES speak_recordings(id) ON DELETE CASCADE
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
offset_msINTEGERNOT NULL DEFAULT 0
duration_secondsINTEGER—
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
room_idTEXTNOT NULL REFERENCES speak_rooms(id) ON DELETE CASCADE
statusTEXTNOT NULL DEFAULT 'scheduled'
—table constraintCHECK (status IN ('scheduled','recording','processing','ready','skipped','failed','deleted'))
skip_reasonTEXT—
planned_start_atTEXTNOT NULL
planned_end_atTEXTNOT NULL
provider_recording_idTEXT—
content_typeTEXT—
bytesINTEGER—
started_atTEXT—
stopped_atTEXT—
duration_secondsINTEGER—
expires_atTEXT—
deleted_atTEXT—
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
room_idTEXTNOT NULL REFERENCES speak_rooms(id) ON DELETE CASCADE
kindTEXTNOT NULL CHECK (kind IN ('host_transfer','host_auto_pass','record','pause','resume','stop','auto_stop','room_end'))
actor_learner_idTEXT—
target_learner_idTEXT—
detailTEXT—
created_atTEXTNOT 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ộtKiểuRàng buộc / ghi chú
room_idTEXTNOT NULL REFERENCES speak_rooms(id) ON DELETE CASCADE
learner_idTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
joined_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
consent_atTEXT—
left_atTEXT—
seen_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY 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ộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
codeTEXTNOT NULL
created_byTEXTNOT NULL REFERENCES learners(id) ON DELETE CASCADE
context_kindTEXTNOT NULL DEFAULT 'none' CHECK (context_kind IN ('none','session','topic'))
context_refTEXT—
statusTEXTNOT NULL DEFAULT 'open' CHECK (status IN ('open','live','ended'))
providerTEXTNOT NULL DEFAULT 'mock'
provider_meeting_idTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
started_atTEXT—
ended_atTEXT—
host_learner_idTEXTREFERENCES 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)