Skip to content

Data Dictionary · Khác (phần 9/11) ​

⚙️ Trang này sinh tự động từ source code bằng scripts/gen-reference.mjs — đừng sửa tay, sửa code rồi chạy npm run gen:reference.

Thuộc Data Dictionary. 29 bảng, outreach_status_log tới referral_milestones.

outreach_status_log ​

migration: 0308_school_codes_and_outreach.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
org_idTEXTNOT NULL REFERENCES outreach_orgs(id) ON DELETE CASCADE
statusTEXTNOT NULL
noteTEXT—
by_userTEXT—
atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: org_id → outreach_orgs

Index: idx_outreach_status_org(org_id, at)

outreach_templates ​

Khuôn thư theo ngôn ngữ, là DỮ LIỆU: sửa chữ không cần deploy. approved = chủ dự án đã duyệt khuôn này; khuôn chưa duyệt thì không gửi thật được (chỉ xem trước). Chỗ điền: .

migration: 0308_school_codes_and_outreach.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY, -- vd 'school-oct-2026-en-initial'
languageTEXTNOT NULL
kindTEXTNOT NULL CHECK (kind IN ('initial', 'reminder'))
subjectTEXTNOT NULL
greetingTEXTNOT NULL
bodyTEXTNOT NULL
promo_urlTEXTNOT NULL
approvedINTEGERNOT NULL DEFAULT 0 CHECK (approved IN (0, 1))
approved_byTEXT—
approved_atTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Index: uq_outreach_template_lang_kind(language, kind) UNIQUE

parent_beliefs ​

---------------------------------------------------------------- parent_beliefs

migration: 0082_english_state_enums.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
user_idTEXTNOT NULL REFERENCES users(id)
subject_idTEXTNOT NULL
worry_levelINTEGERNOT NULL CHECK (worry_level BETWEEN 1 AND 5)
perceived_stateTEXTCHECK (perceived_state IN
predicted_scoreREAL—
score_scaleREALNOT NULL DEFAULT 10
will_passINTEGER—
noteTEXT—
recorded_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners · user_id → users

Index: idx_parent_beliefs(learner_id, subject_id, recorded_at)

parent_course_journeys ​

Migration 0334 - parent course journeys. SRC-1252. ## Vì sao migration này tồn tại Khoá học của bố mẹ (marlins.nemo12.com/parent-courses) đổi từ "đọc rồi làm quiz" sang một vòng sáu hành trình GOAL, UNDERSTAND, LEARN, TRY, REFLECT, DECIDE ở cấp khoá, và năm bước GOAL, LEARN, TRY, REFLECT, NEXT ở cấp chỗ học. Những gì bố mẹ tự viết ra trong vòng ấy (mục tiêu, điểm xuất phát của gia đình, phản ứng của con khi thử, điều rút ra, quyết định tiếp theo) phải theo họ sang máy khác và phải đi vào Parent Model. parent_course_progress chỉ có chỗ cho mốc đọc và điểm quiz, nên không có bảng này thì mọi câu trả lời chỉ sống trong một trình duyệt. Một dòng là MỘT bước của MỘT phụ huynh ở MỘT chỗ: bước cấp khoá dùng unit_index = -1 và lesson_index = -1. Nội dung là JSON vì mỗi bước hỏi một bộ câu khác nhau; luật hình dạng nằm ở zod trong workers/api/src/modules/parent/journeys.ts, không nằm ở đây. ## Chạy lại không đổi gì Chỉ có CREATE ... IF NOT EXISTS.

migration: 0334_parent_course_journeys.sql

CộtKiểuRàng buộc / ghi chú
user_idTEXTNOT NULL REFERENCES users(id) ON DELETE CASCADE
course_codeTEXTNOT NULL
unit_indexINTEGERNOT NULL DEFAULT -1
lesson_indexINTEGERNOT NULL DEFAULT -1
stepTEXTNOT NULL CHECK (step IN ('goal','understand','try','reflect','decide'))
data_jsonTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (user_id, course_code, unit_index, lesson_index, step)

Khóa ngoại: user_id → users

Index: idx_parent_course_journeys_user(user_id, course_code)

parent_course_progress ​

Tiến độ đọc và điểm quiz của BỐ MẸ trong khoá học cho bố mẹ (SRC-671, REQ-PAR-18). VÌ SAO KHÔNG DÙNG LẠI curriculum_part_completions / curriculum_stage_completions: hai bảng kia khoá theo learner_id và theo lesson trong CSDL nội dung của học sinh. Ở đây người học là NGƯỜI LỚN (users.id), và nội dung không nằm trong CSDL mà nằm trong mã nguồn của app dưới dạng 15 khoá tĩnh. Nhét vào bảng của learner thì mọi câu đếm tiến độ của học sinh phải nhớ loại trừ người lớn ra, và sẽ có ngày một câu quên loại trừ. KHOÁ CHÍNH là (user_id, course_code, unit_index, lesson_index): một người, một chỗ học, đúng một dòng. Làm quiz lại thì CẬP NHẬT dòng cũ chứ không thêm dòng — lịch sử từng lần làm không phải câu hỏi mà màn hình này trả lời; câu hỏi của nó là "còn hổng chỗ nào NGAY BÂY GIỜ". course_code cố tình KHÔNG có khoá ngoại: danh mục khoá nằm trong mã nguồn (parentCourses.ts), không có bảng nào để trỏ tới. Đổi lại, mã đọc phải chịu được dòng trỏ tới khoá đã bị xoá.

migration: 0197_parent_course_progress.sql

CộtKiểuRàng buộc / ghi chú
user_idTEXTNOT NULL REFERENCES users(id) ON DELETE CASCADE
course_codeTEXTNOT NULL
unit_indexINTEGERNOT NULL
lesson_indexINTEGERNOT NULL
read_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
quiz_attemptsINTEGERNOT NULL DEFAULT 0
quiz_totalINTEGER—
last_correctINTEGER—
best_correctINTEGER—
missed_jsonTEXT—
quiz_atTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (user_id, course_code, unit_index, lesson_index)

Khóa ngoại: user_id → users

Index: idx_parent_course_progress_user(user_id, course_code)

parent_digest_sends ​

Migration 0327 - parent digest one row per recipient. SRC-1204. ## Vì sao migration này tồn tại Thư tổng kết ngày được dựng cho từng cặp (con, email bố mẹ), nhưng chốt chặn trùng ở parent_digest_sends có khoá chính (learner_id, digest_date). Con có HAI phụ huynh thì người thứ nhất giành được dòng, người thứ hai bị INSERT OR IGNORE bỏ qua và bị đếm là "đã gửi": không bao giờ nhận thư, không có log nào nói ra (audit 03.10.2026). Khoá chính phải có cả người nhận. SQLite không đổi được khoá chính tại chỗ nên dựng lại bảng. ## Chạy lại không đổi gì Dựng bảng mới IF NOT EXISTS, chép bằng INSERT OR IGNORE, rồi thay bảng cũ. Chạy lần hai thì bảng "cũ" đã mang khoá mới, chép sang nguyên vẹn và thay lại: cùng một kết quả.

migration: 0327_parent_digest_one_row_per_recipient.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id)
digest_dateTEXTNOT NULL
to_emailTEXTNOT NULL
intended_emailTEXTNOT NULL
statusTEXTNOT NULL CHECK (status IN ('sent','failed','skipped'))
provider_idTEXT—
errorTEXT—
itemsINTEGERNOT NULL DEFAULT 0
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (learner_id, digest_date, intended_email)

Khóa ngoại: learner_id → learners

Index: idx_digest_sends_date(digest_date, status)

parent_ebook_progress ​

Migration 0330 - parent ebook reading progress. SRC-1228. ## Vì sao migration này tồn tại Lượt tính Parent Model đầu tiên (04.10.2026) cho ra một con số nói thẳng: 26/32 phụ huynh không có mẩu bằng chứng nào ở chiều "tự học". Không phải vì họ không học, mà vì cổng này chỉ ghi lại đúng MỘT loại việc học: đọc một chỗ học trong khoá dành cho bố mẹ (6 dòng trên toàn production). Thư viện Marlins có 20 quyển sách và trang đọc đã chạy từ SRC-673, nhưng đọc xong một chương thì không để lại dấu vết nào - mở trang, đọc, đóng lại, hệ không biết gì cả. Bảng này là chỗ ghi dấu ấy. Nó cũng là thứ cho phép trang đọc sau này nói "bạn đang đọc dở quyển nào", việc mà hôm nay không làm được. ## MỞ và ĐỌC XONG là hai việc khác nhau Hai cột thời gian chứ không phải một cờ: mở một chương rồi đóng ngay là một tín hiệu YẾU về việc tự học, còn đọc tới hết chương là một tín hiệu mạnh. Gộp cả hai thành "đã đọc" là biến một cú bấm nhầm thành bằng chứng ngang với một buổi ngồi đọc thật. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS và CREATE INDEX IF NOT EXISTS; không câu nào ghi dữ liệu.

migration: 0330_parent_ebook_reading_progress.sql

CộtKiểuRàng buộc / ghi chú
user_idTEXTNOT NULL REFERENCES users(id) ON DELETE CASCADE
book_slugTEXTNOT NULL
chapter_indexINTEGERNOT NULL
opened_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
finished_atTEXT—
opensINTEGERNOT NULL DEFAULT 1
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (user_id, book_slug, chapter_index)

Khóa ngoại: user_id → users

Index: idx_parent_ebook_user(user_id, updated_at DESC)

parent_evidence ​

── Evidence Registry của phụ huynh ───────────────────────────────────────────────────────── BẤT BIẾN như sổ của learner: một mẩu đã ghi thì không sửa. Muốn nói điều khác thì ghi mẩu mới. Nhờ vậy mọi ảnh chụp model cũ vẫn giải thích lại được bằng đúng số liệu đã sinh ra nó.

migration: 0328_parent_evidence_registry_and_parent_model.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
event_idTEXTNOT NULL UNIQUE
user_idTEXTNOT NULL REFERENCES users(id) ON DELETE CASCADE
family_idTEXT—
about_learner_idTEXT—
sourceTEXTNOT NULL
typeTEXTNOT NULL
dimensionTEXTNOT NULL CHECK (dimension IN ('learning','understanding','acting','presence'))
observedREALNOT NULL
reliabilityREALNOT NULL
payload_jsonTEXT—
occurred_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
recorded_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: user_id → users

Index: idx_parent_evidence_user(user_id, occurred_at DESC) · idx_parent_evidence_dim(user_id, dimension) · idx_parent_evidence_about(about_learner_id)

parent_lesson_gates ​

migration: 0335_lesson_gates.sql

CộtKiểuRàng buộc / ghi chú
user_idTEXTNOT NULL REFERENCES users(id) ON DELETE CASCADE
course_codeTEXTNOT NULL
unit_indexINTEGERNOT NULL
lesson_indexINTEGERNOT NULL
part_indexINTEGERNOT NULL
attemptsINTEGERNOT NULL DEFAULT 0
last_correctINTEGER—
last_totalINTEGER—
failed_atTEXT—
passed_atTEXT—
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (user_id, course_code, unit_index, lesson_index, part_index)

Khóa ngoại: user_id → users

Index: idx_parent_lesson_gates_user(user_id, course_code)

parent_models ​

── Parent Model ──────────────────────────────────────────────────────────────────────────── Cùng khuôn với ielts_learner_models (0229): mỗi lần tính ra một ẢNH CHỤP mới, không sửa đè ảnh cũ. Model là một kết luận, mà kết luận thì phải xem lại được nó đã đổi thế nào.

migration: 0328_parent_evidence_registry_and_parent_model.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
user_idTEXTNOT NULL REFERENCES users(id) ON DELETE CASCADE
engine_versionINTEGERNOT NULL
evidence_countINTEGERNOT NULL DEFAULT 0
evidence_throughTEXT—
learningREAL, understanding REAL, acting REAL, presence REAL
confidenceREALNOT NULL DEFAULT 0
layer_jsonTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: user_id → users

Index: idx_parent_models_user(user_id, created_at DESC)

partner_attributions ​

Người mua thuộc về đối tác nào. buyer_user_id UNIQUE: first touch thắng, trọn đời.

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
buyer_user_idTEXTPRIMARY KEY REFERENCES users(id)
partner_idTEXTNOT NULL REFERENCES partners(id)
campaign_idTEXT—
product_lineTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
buyer_discount_bpINTEGERNOT NULL DEFAULT 0 — thêm ở 0305_partner_discount_and_tax.sql

Khóa ngoại: buyer_user_id → users · partner_id → partners

Index: idx_partner_attributions_partner(partner_id)

partner_campaigns ​

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
partner_idTEXTNOT NULL REFERENCES partners(id)
slugTEXTNOT NULL
product_lineTEXTNOT NULL CHECK (product_line IN ('ielts', 'sat', 'speaking'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintUNIQUE (partner_id, slug)

Khóa ngoại: partner_id → partners

partner_clicks ​

Lượt bấm: chỉ để đếm. Không lưu IP thô.

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
idINTEGERPRIMARY KEY AUTOINCREMENT
partner_idTEXTNOT NULL REFERENCES partners(id)
campaign_idTEXT—
product_lineTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: partner_id → partners

Index: idx_partner_clicks_partner(partner_id, created_at)

partner_commissions ​

Sổ hoa hồng: CHỈ CHÈN. Số dư = SUM. Hoàn tiền = dòng âm (kind='reverse').

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
partner_idTEXTNOT NULL REFERENCES partners(id)
payment_refTEXTNOT NULL REFERENCES partner_sales(payment_ref)
tierINTEGERNOT NULL CHECK (tier IN (1, 2))
kindTEXTNOT NULL CHECK (kind IN ('earn', 'reverse'))
base_amount_vndINTEGERNOT NULL
rate_bpINTEGERNOT NULL
amount_vndINTEGERNOT NULL
available_atTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintUNIQUE (payment_ref, tier, kind)

Khóa ngoại: partner_id → partners · payment_ref → partner_sales

Index: idx_partner_commissions_partner(partner_id)

partner_payouts ​

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
partner_idTEXTNOT NULL REFERENCES partners(id)
amount_vndINTEGERNOT NULL CHECK (amount_vnd > 0)
statusTEXTNOT NULL DEFAULT 'requested' CHECK (status IN ('requested', 'paid', 'cancelled'))
scheduled_forTEXTNOT NULL
bank_refTEXT—
requested_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
paid_atTEXT—
paid_byTEXT—
tax_rate_bpINTEGERNOT NULL DEFAULT 0 — thêm ở 0305_partner_discount_and_tax.sql
tax_withheld_vndINTEGERNOT NULL DEFAULT 0 — thêm ở 0305_partner_discount_and_tax.sql
net_amount_vndINTEGER— thêm ở 0305_partner_discount_and_tax.sql
tax_idTEXT— thêm ở 0305_partner_discount_and_tax.sql

Khóa ngoại: partner_id → partners

Index: idx_partner_payouts_partner(partner_id) · idx_partner_payouts_one_open(partner_id) UNIQUE

partner_rates ​

Tỷ lệ theo dòng sản phẩm và tầng. Không nằm trong pricing_items vì bảng ấy được trang chủ công khai đọc; tỷ lệ của người bán không phải thứ phụ huynh cần thấy.

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
product_lineTEXTNOT NULL CHECK (product_line IN ('ielts', 'sat', 'speaking'))
tierINTEGERNOT NULL CHECK (tier IN (1, 2))
rate_bpINTEGERNOT NULL CHECK (rate_bp >= 0 AND rate_bp <= 5000)
—table constraintPRIMARY KEY (product_line, tier)

partner_sales ​

Một khoản người mua THỰC TRẢ. Chưa có cổng thanh toán nên admin ghi; ngày có cổng thì cổng gọi cùng một hàm. payment_ref UNIQUE: ghi hai lần một khoản không thành hai khoản.

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
payment_refTEXTPRIMARY KEY
buyer_user_idTEXTNOT NULL REFERENCES users(id)
partner_idTEXTNOT NULL REFERENCES partners(id)
product_lineTEXTNOT NULL CHECK (product_line IN ('ielts', 'sat', 'speaking'))
amount_vndINTEGERNOT NULL CHECK (amount_vnd > 0)
paid_atTEXTNOT NULL
refunded_atTEXT—
recorded_byTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: buyer_user_id → users · partner_id → partners

partners ​

Migration 0304 - partner program (partners.nemo12.com). SRC-1089, SDD-046. ## Vì sao migration này tồn tại Người bán chuyên nghiệp (affiliate) cần một hệ ghi công và một sổ hoa hồng TIỀN MẶT riêng, tách khỏi referral của learner (referral_*), vì hai luật của referral learner sai với người bán: "hai bên cùng vai" (bảo vệ trẻ con) và "hoa hồng vào tài khoản ảo tiêu vào học phí". Nới hai luật ấy tại chỗ là mở lỗ ở một hệ đang chạy; nên người bán có bảng riêng. Chốt với chủ dự án 27.09.2026: tự duyệt; ghi công ngay; hoa hồng rút được sau 30 ngày; số dư phải đạt 5.000.000 mới rút, mỗi lần rút tối thiểu 3.000.000, và luôn để lại 1.000.000 cọc; trả ngày 5 hằng tháng; tầng hai 2%. Các con số tiền nằm trong code (partners/policy.ts), tỷ lệ theo dòng sản phẩm nằm ở partner_rates dưới đây để sửa không cần deploy. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING.

migration: 0304_partner_program.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
user_idTEXTNOT NULL UNIQUE REFERENCES users(id)
codeTEXTNOT NULL UNIQUE
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended'))
full_nameTEXTNOT NULL
phoneTEXTNOT NULL
channelTEXT—
terms_versionTEXTNOT NULL
upline_partner_idTEXTREFERENCES partners(id)
bank_nameTEXT—
bank_accountTEXT—
bank_holderTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintCHECK (upline_partner_id IS NULL OR upline_partner_id <> id)
tax_idTEXT— thêm ở 0305_partner_discount_and_tax.sql

Khóa ngoại: user_id → users · upline_partner_id → partners

photo_album_items ​

alt_text NOT NULL là có chủ đích: album là bằng chứng niềm tin, mà một trang toàn ảnh không alt text thì với người dùng trình đọc màn hình là một trang trống (SDD-019 §5).

migration: 0046_mentor_profiles_albums.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
album_idTEXTNOT NULL REFERENCES photo_albums(id)
media_idTEXTNOT NULL REFERENCES media(id)
alt_textTEXTNOT NULL
captionTEXT—
caption_parentTEXT—
caption_studentTEXT—
display_orderINTEGERNOT NULL DEFAULT 100
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
descriptionTEXT— thêm ở 0323_photo_item_description.sql
taken_atTEXT— thêm ở 0324_photo_album_item_metadata.sql
locationTEXT— thêm ở 0324_photo_album_item_metadata.sql
original_filenameTEXT— thêm ở 0324_photo_album_item_metadata.sql
source_refTEXT— thêm ở 0324_photo_album_item_metadata.sql

Khóa ngoại: album_id → photo_albums · media_id → media

Index: idx_photo_album_items_album(album_id, display_order) · idx_photo_album_items_source(source_ref) UNIQUE

photo_album_people ​

Gắn người ở tầng album, không ở tầng từng ảnh (Q-133).

migration: 0046_mentor_profiles_albums.sql

CộtKiểuRàng buộc / ghi chú
album_idTEXTNOT NULL REFERENCES photo_albums(id)
mentor_profile_idTEXTNOT NULL REFERENCES mentor_profiles(id)
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (album_id, mentor_profile_id)

Khóa ngoại: album_id → photo_albums · mentor_profile_id → mentor_profiles

Index: idx_photo_album_people_mentor(mentor_profile_id)

photo_albums ​

photo_albums — mỗi trường chữ có 3 cột: gốc + bản bố mẹ + bản học sinh (SDD-019 §6). audience là chuyện KHÁC: nó quyết album có được xuất hiện ở vùng đó không.

migration: 0046_mentor_profiles_albums.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
slugTEXTNOT NULL UNIQUE
titleTEXTNOT NULL
title_parentTEXT—
title_studentTEXT—
captionTEXT—
caption_parentTEXT—
caption_studentTEXT—
descriptionTEXT—
description_parentTEXT—
description_studentTEXT—
cover_media_idTEXTREFERENCES media(id)
audienceTEXTNOT NULL DEFAULT 'both' CHECK (audience IN ('both','parent','student'))
statusTEXTNOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived'))
featuredINTEGERNOT NULL DEFAULT 0 CHECK (featured IN (0,1))
display_orderINTEGERNOT NULL DEFAULT 100
taken_onTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
source_refTEXT— thêm ở 0324_photo_album_item_metadata.sql

Khóa ngoại: cover_media_id → media

Index: idx_photo_albums_public(status, audience, display_order) · idx_photo_albums_source(source_ref) UNIQUE

pricing_items ​

Migration 0177 — Bảng giá công khai của khoá IELTS (SRC-643). Chủ dự án chốt 2026-08-30: học phí, gói bài tập tự chấm, combo bốn kỹ năng, thưởng top tuần/tháng và hoa hồng giới thiệu. Yêu cầu "đưa vào database" chứ không để trong mã nguồn. ## Vì sao vào DB trong khi danh mục gói học giữ giá trong mã (Danh mục ấy nằm ở apps/web/src/packages.ts khi viết dòng này; từ 2026-09-19 nó chuyển từ hai bản trong app sang một gói dùng chung packages/catalog — SRC-867. Chỉ đường dẫn đổi, lập luận dưới đây không đổi.) Hai thứ khác nhau, và ranh giới phải rõ để về sau không ai phải đoán: | | packages.ts (mã nguồn) | Bảng này (D1) | | --- | --- | --- | | Cái gì | CHƯƠNG TRÌNH: Nemo Dive · Swim · 360 · Marlin Start/Compass/Mind | KHOÁ HỌC: học phí IELTS, gói bài tập, combo, thưởng | | Đổi giá bằng cách nào | sửa mã + deploy | sửa dòng trong D1 | Giá khoá đổi theo đợt khai giảng và người đổi không nhất thiết biết code, nên nó thuộc về dữ liệu. Giá chương trình gắn với mô tả dài trong cùng file và đổi rất hiếm, nên để yên tại chỗ — kéo nó sang đây chỉ tạo thêm một chỗ phải đồng bộ mà không giải quyết vấn đề nào. ## Vì sao lưu SỐ chứ không lưu chuỗi đã định dạng Cùng lý do đã ghi trong packages.ts: số chỉ xuất hiện đúng một lần, còn cách viết do Intl.NumberFormat lo. Lưu "3.200.000 ₫" thì sớm muộn có dòng ghi "3tr2", dòng ghi "3,200,000", và không ai biết dòng nào mới đúng.

migration: 0177_pricing.sql

CộtKiểuRàng buộc / ghi chú
codeTEXTPRIMARY KEY
kindTEXTNOT NULL CHECK (kind IN ('tuition', 'addon', 'bundle', 'reward', 'referral'))
name_viTEXTNOT NULL
amount_vndINTEGER—
unit_viTEXT—
list_amount_vndINTEGER—
percentINTEGER—
note_viTEXT—
seqINTEGERNOT NULL
activeINTEGERNOT NULL DEFAULT 1

Index: idx_pricing_items_active(active, seq)

product_updates ​

migration: 0337_mail_series_state_and_product_updates.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
programTEXTNOT NULL
title_viTEXTNOT NULL
body_viTEXTNOT NULL
urlTEXTNOT NULL
statusTEXTNOT NULL DEFAULT 'draft'
published_atTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Index: idx_product_updates_program(program, status, published_at DESC)

program_joins ​

Migration 0345 - program joins. SRC-1336. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 10.10.2026: trang tổng quan mỗi chương trình có nút "Tham gia"; thư THEO LỊCH của chương trình (chuỗi thư, báo cáo tuần, Learning Plan) chỉ bắt đầu sau khi bấm. Thư xác nhận và thư sau buổi học vẫn đi như cũ. Người ĐANG học từ trước được tính là đã tham gia (quyết định của chủ dự án), nên migration này ghi sẵn một dòng grandfathered cho họ: không có dòng ấy thì đúng lượt cron đầu sau deploy, mọi gia đình đang học mất thư tuần mà không ai báo. Một dòng mỗi (user, chương trình). Tham gia là của MỘT người dùng; bộ gửi coi một học sinh là đã tham gia khi chính em, hoặc một phụ huynh (owner/guardian) của gia đình em, có dòng ở đây. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS, và mọi câu ghi sẵn là INSERT OR IGNORE.

migration: 0345_program_joins.sql

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

Khóa ngoại: user_id → users

Index: idx_program_joins_program(program)

province_aliases ​

Bí danh: mọi cách người ta thật sự gõ, quy về một mã. ĐÂY là thứ làm một bảng cứng chịu được thực tế, và là thứ migration 0221 không có. alias lưu dạng ĐÃ CHUẨN HOÁ (thường, bỏ dấu, bỏ tiền tố "tỉnh"/"thành phố"/"tp"), để 'Hà Nội', 'hà nội', 'HA NOI' và 'TP Hà Nội' rơi vào cùng một dòng thay vì bốn dòng. Phép chuẩn hoá ấy nằm ở tầng code (geography/normalize.ts) và phải giống hệt ở cả lượt nạp lẫn lượt tra — nếu hai bên lệch nhau thì bảng này im lặng không khớp gì cả.

migration: 0279_learner_geography_country_and_province_codes.sql

CộtKiểuRàng buộc / ghi chú
aliasTEXTPRIMARY KEY
province_codeTEXTNOT NULL REFERENCES provinces(code)

Khóa ngoại: province_code → provinces

provinces ​

─── 2. Tỉnh/thành/bang, có mã ───────────────────────────────────────────────────────────────── Một bảng cho MỌI quốc gia, không phải một bảng riêng cho Việt Nam: chỉ đạo nói "tỉnh/thành phố/ bang", tức là chiều thứ hai phải chịu được cả state của Mỹ lẫn tỉnh của Việt Nam. Khác biệt giữa chúng là chữ NHÃN, không phải cấu trúc.

migration: 0279_learner_geography_country_and_province_codes.sql

CộtKiểuRàng buộc / ghi chú
codeTEXTPRIMARY KEY
country_codeTEXTNOT NULL
—table constraintCHECK (length(country_code) = 2 AND country_code = upper(country_code))
name_viTEXTNOT NULL
name_enTEXTNOT NULL
kindTEXTNOT NULL DEFAULT 'tinh'
seqINTEGERNOT NULL DEFAULT 1
activeINTEGERNOT NULL DEFAULT 1

Index: idx_provinces_country(country_code, active, seq)

referral_attributions ​

Ai giới thiệu ai. referred_user_id là KHOÁ CHÍNH nên mỗi người chỉ được ghi công đúng MỘT lần trong đời: first-touch thắng, không ai đổi được người giới thiệu của mình về sau. Đây là ràng buộc chống gian lận đặt ở tầng dữ liệu, không phải ở tầng code.

migration: 0064_referrals.sql

CộtKiểuRàng buộc / ghi chú
referred_user_idTEXTPRIMARY KEY REFERENCES users(id)
referrer_user_idTEXTNOT NULL REFERENCES users(id)
codeTEXTNOT NULL
source_appTEXTNOT NULL CHECK (source_app IN ('marlins','learn'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintCHECK (referred_user_id <> referrer_user_id) -- không tự giới thiệu chính mình

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

Index: idx_referral_attr_referrer(referrer_user_id)

referral_codes ​

Mỗi người MỘT mã, sinh khi họ mở trang giới thiệu lần đầu.

migration: 0064_referrals.sql

CộtKiểuRàng buộc / ghi chú
codeTEXTPRIMARY KEY, -- chuỗi ngắn, phân biệt HOA/thường khi tra thì chuẩn hoá về hoa
user_idTEXTNOT NULL UNIQUE REFERENCES users(id), -- UNIQUE: một người không được có hai mã
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: user_id → users

referral_milestones ​

Ghi nhận hoạt động của người ĐƯỢC giới thiệu (SRC-754, REQ-GRW-02; nối tiếp SRC-450). VÌ SAO CẦN BẢNG NÀY: referral_attributions trả lời được đúng một câu — "ai giới thiệu ai" — rồi im lặng mãi mãi. Người giới thiệu mở trang chỉ thấy một dòng tên và chữ "đang chờ", không biết người mình rủ vào có dùng Nemo12 thật hay đã bỏ đi sau một lần đăng nhập. Mà đó mới là câu hỏi khiến người ta rủ tiếp người thứ hai. VÌ SAO LÀ MỘT BẢNG CHỨ KHÔNG PHẢI ĐẾM LẠI MỖI LẦN HỎI: mốc là chuyện xảy ra MỘT LẦN tại MỘT THỜI ĐIỂM. Tính lại từ dữ liệu thô mỗi lần đọc thì "lần đầu tiên" trôi theo dữ liệu — một learner bị lưu trữ, một dòng evidence bị xoá, và cái mốc đã báo cho người giới thiệu tuần trước biến mất khỏi màn hình tuần này. Ghi lại một lần rồi thôi là cách duy nhất để câu chuyện kể cho người giới thiệu không tự sửa sau lưng họ. KHOÁ CHÍNH (referred_user_id, milestone): mỗi người mỗi mốc đúng một dòng, trọn đời. Nhờ vậy lượt quét chỉ cần INSERT OR IGNORE và chạy lại bao nhiêu lần cũng không sinh dòng thừa — tính bình thản (idempotent) đặt ở tầng dữ liệu, không phải ở tầng code nhớ kiểm tra.

migration: 0226_referral_milestones.sql

CộtKiểuRàng buộc / ghi chú
referred_user_idTEXTNOT NULL REFERENCES users(id)
referrer_user_idTEXTNOT NULL REFERENCES users(id)
milestoneTEXTNOT NULL CHECK (milestone IN ('joined','learner_linked','diagnostic_done','first_lesson','active_week','course_enrolled','paid'))
occurred_atTEXTNOT NULL
ref_idTEXT—
notified_atTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (referred_user_id, milestone)

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

Index: idx_referral_milestones_notify(referrer_user_id, notified_at)