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

# 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](../data-dictionary.md). 29 bảng, `outreach_status_log` tới `referral_milestones`.

## `outreach_status_log`

migration: `0308_school_codes_and_outreach.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `org_id` | TEXT | NOT NULL REFERENCES outreach_orgs(id) ON DELETE CASCADE |
| `status` | TEXT | NOT NULL |
| `note` | TEXT | — |
| `by_user` | TEXT | — |
| `at` | TEXT | NOT 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: {{greeting}} {{school_name}} {{country}} {{promo_url}}.

migration: `0308_school_codes_and_outreach.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY,                         -- vd 'school-oct-2026-en-initial' |
| `language` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('initial', 'reminder')) |
| `subject` | TEXT | NOT NULL |
| `greeting` | TEXT | NOT NULL |
| `body` | TEXT | NOT NULL |
| `promo_url` | TEXT | NOT NULL |
| `approved` | INTEGER | NOT NULL DEFAULT 0 CHECK (approved IN (0, 1)) |
| `approved_by` | TEXT | — |
| `approved_at` | TEXT | — |
| `updated_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `subject_id` | TEXT | NOT NULL |
| `worry_level` | INTEGER | NOT NULL CHECK (worry_level BETWEEN 1 AND 5) |
| `perceived_state` | TEXT | CHECK (perceived_state IN |
| `predicted_score` | REAL | — |
| `score_scale` | REAL | NOT NULL DEFAULT 10 |
| `will_pass` | INTEGER | — |
| `note` | TEXT | — |
| `recorded_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE |
| `course_code` | TEXT | NOT NULL |
| `unit_index` | INTEGER | NOT NULL DEFAULT -1 |
| `lesson_index` | INTEGER | NOT NULL DEFAULT -1 |
| `step` | TEXT | NOT NULL CHECK (step IN ('goal','understand','try','reflect','decide')) |
| `data_json` | TEXT | NOT NULL |
| `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* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE |
| `course_code` | TEXT | NOT NULL |
| `unit_index` | INTEGER | NOT NULL |
| `lesson_index` | INTEGER | NOT NULL |
| `read_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `quiz_attempts` | INTEGER | NOT NULL DEFAULT 0 |
| `quiz_total` | INTEGER | — |
| `last_correct` | INTEGER | — |
| `best_correct` | INTEGER | — |
| `missed_json` | TEXT | — |
| `quiz_at` | TEXT | — |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `learner_id` | TEXT | NOT NULL REFERENCES learners(id) |
| `digest_date` | TEXT | NOT NULL |
| `to_email` | TEXT | NOT NULL |
| `intended_email` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL CHECK (status IN ('sent','failed','skipped')) |
| `provider_id` | TEXT | — |
| `error` | TEXT | — |
| `items` | INTEGER | NOT NULL DEFAULT 0 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE |
| `book_slug` | TEXT | NOT NULL |
| `chapter_index` | INTEGER | NOT NULL |
| `opened_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `finished_at` | TEXT | — |
| `opens` | INTEGER | NOT NULL DEFAULT 1 |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `event_id` | TEXT | NOT NULL UNIQUE |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE |
| `family_id` | TEXT | — |
| `about_learner_id` | TEXT | — |
| `source` | TEXT | NOT NULL |
| `type` | TEXT | NOT NULL |
| `dimension` | TEXT | NOT NULL CHECK (dimension IN ('learning','understanding','acting','presence')) |
| `observed` | REAL | NOT NULL |
| `reliability` | REAL | NOT NULL |
| `payload_json` | TEXT | — |
| `occurred_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `recorded_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE |
| `course_code` | TEXT | NOT NULL |
| `unit_index` | INTEGER | NOT NULL |
| `lesson_index` | INTEGER | NOT NULL |
| `part_index` | INTEGER | NOT NULL |
| `attempts` | INTEGER | NOT NULL DEFAULT 0 |
| `last_correct` | INTEGER | — |
| `last_total` | INTEGER | — |
| `failed_at` | TEXT | — |
| `passed_at` | TEXT | — |
| `updated_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE |
| `engine_version` | INTEGER | NOT NULL |
| `evidence_count` | INTEGER | NOT NULL DEFAULT 0 |
| `evidence_through` | TEXT | — |
| `learning` | REAL | , understanding REAL, acting REAL, presence REAL |
| `confidence` | REAL | NOT NULL DEFAULT 0 |
| `layer_json` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `buyer_user_id` | TEXT | PRIMARY KEY REFERENCES users(id) |
| `partner_id` | TEXT | NOT NULL REFERENCES partners(id) |
| `campaign_id` | TEXT | — |
| `product_line` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `buyer_discount_bp` | INTEGER | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `partner_id` | TEXT | NOT NULL REFERENCES partners(id) |
| `slug` | TEXT | NOT NULL |
| `product_line` | TEXT | NOT NULL CHECK (product_line IN ('ielts', 'sat', 'speaking')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `UNIQUE (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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `partner_id` | TEXT | NOT NULL REFERENCES partners(id) |
| `campaign_id` | TEXT | — |
| `product_line` | TEXT | — |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `partner_id` | TEXT | NOT NULL REFERENCES partners(id) |
| `payment_ref` | TEXT | NOT NULL REFERENCES partner_sales(payment_ref) |
| `tier` | INTEGER | NOT NULL CHECK (tier IN (1, 2)) |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('earn', 'reverse')) |
| `base_amount_vnd` | INTEGER | NOT NULL |
| `rate_bp` | INTEGER | NOT NULL |
| `amount_vnd` | INTEGER | NOT NULL |
| `available_at` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `UNIQUE (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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `partner_id` | TEXT | NOT NULL REFERENCES partners(id) |
| `amount_vnd` | INTEGER | NOT NULL CHECK (amount_vnd > 0) |
| `status` | TEXT | NOT NULL DEFAULT 'requested' CHECK (status IN ('requested', 'paid', 'cancelled')) |
| `scheduled_for` | TEXT | NOT NULL |
| `bank_ref` | TEXT | — |
| `requested_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `paid_at` | TEXT | — |
| `paid_by` | TEXT | — |
| `tax_rate_bp` | INTEGER | NOT NULL DEFAULT 0 — thêm ở 0305_partner_discount_and_tax.sql |
| `tax_withheld_vnd` | INTEGER | NOT NULL DEFAULT 0 — thêm ở 0305_partner_discount_and_tax.sql |
| `net_amount_vnd` | INTEGER |  — thêm ở 0305_partner_discount_and_tax.sql |
| `tax_id` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `product_line` | TEXT | NOT NULL CHECK (product_line IN ('ielts', 'sat', 'speaking')) |
| `tier` | INTEGER | NOT NULL CHECK (tier IN (1, 2)) |
| `rate_bp` | INTEGER | NOT NULL CHECK (rate_bp >= 0 AND rate_bp <= 5000) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `payment_ref` | TEXT | PRIMARY KEY |
| `buyer_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `partner_id` | TEXT | NOT NULL REFERENCES partners(id) |
| `product_line` | TEXT | NOT NULL CHECK (product_line IN ('ielts', 'sat', 'speaking')) |
| `amount_vnd` | INTEGER | NOT NULL CHECK (amount_vnd > 0) |
| `paid_at` | TEXT | NOT NULL |
| `refunded_at` | TEXT | — |
| `recorded_by` | TEXT | — |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `user_id` | TEXT | NOT NULL UNIQUE REFERENCES users(id) |
| `code` | TEXT | NOT NULL UNIQUE |
| `status` | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')) |
| `full_name` | TEXT | NOT NULL |
| `phone` | TEXT | NOT NULL |
| `channel` | TEXT | — |
| `terms_version` | TEXT | NOT NULL |
| `upline_partner_id` | TEXT | REFERENCES partners(id) |
| `bank_name` | TEXT | — |
| `bank_account` | TEXT | — |
| `bank_holder` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `CHECK (upline_partner_id IS NULL OR upline_partner_id <> id)` |
| `tax_id` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `album_id` | TEXT | NOT NULL REFERENCES photo_albums(id) |
| `media_id` | TEXT | NOT NULL REFERENCES media(id) |
| `alt_text` | TEXT | NOT NULL |
| `caption` | TEXT | — |
| `caption_parent` | TEXT | — |
| `caption_student` | TEXT | — |
| `display_order` | INTEGER | NOT NULL DEFAULT 100 |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| `description` | TEXT |  — thêm ở 0323_photo_item_description.sql |
| `taken_at` | TEXT |  — thêm ở 0324_photo_album_item_metadata.sql |
| `location` | TEXT |  — thêm ở 0324_photo_album_item_metadata.sql |
| `original_filename` | TEXT |  — thêm ở 0324_photo_album_item_metadata.sql |
| `source_ref` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `album_id` | TEXT | NOT NULL REFERENCES photo_albums(id) |
| `mentor_profile_id` | TEXT | NOT NULL REFERENCES mentor_profiles(id) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `slug` | TEXT | NOT NULL UNIQUE |
| `title` | TEXT | NOT NULL |
| `title_parent` | TEXT | — |
| `title_student` | TEXT | — |
| `caption` | TEXT | — |
| `caption_parent` | TEXT | — |
| `caption_student` | TEXT | — |
| `description` | TEXT | — |
| `description_parent` | TEXT | — |
| `description_student` | TEXT | — |
| `cover_media_id` | TEXT | REFERENCES media(id) |
| `audience` | TEXT | NOT NULL DEFAULT 'both' CHECK (audience IN ('both','parent','student')) |
| `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 |
| `taken_on` | 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')) |
| `source_ref` | TEXT |  — 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `code` | TEXT | PRIMARY KEY |
| `kind` | TEXT | NOT NULL CHECK (kind IN ('tuition', 'addon', 'bundle', 'reward', 'referral')) |
| `name_vi` | TEXT | NOT NULL |
| `amount_vnd` | INTEGER | — |
| `unit_vi` | TEXT | — |
| `list_amount_vnd` | INTEGER | — |
| `percent` | INTEGER | — |
| `note_vi` | TEXT | — |
| `seq` | INTEGER | NOT NULL |
| `active` | INTEGER | NOT NULL DEFAULT 1 |

**Index:** `idx_pricing_items_active`(active, seq)

## `product_updates`

migration: `0337_mail_series_state_and_product_updates.sql`

| Cột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `program` | TEXT | NOT NULL |
| `title_vi` | TEXT | NOT NULL |
| `body_vi` | TEXT | NOT NULL |
| `url` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT 'draft' |
| `published_at` | TEXT | — |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `program` | TEXT | NOT NULL |
| `source` | TEXT | NOT NULL |
| `joined_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `alias` | TEXT | PRIMARY KEY |
| `province_code` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `code` | TEXT | PRIMARY KEY |
| `country_code` | TEXT | NOT NULL |
| — | *table constraint* | `CHECK (length(country_code) = 2 AND country_code = upper(country_code))` |
| `name_vi` | TEXT | NOT NULL |
| `name_en` | TEXT | NOT NULL |
| `kind` | TEXT | NOT NULL DEFAULT 'tinh' |
| `seq` | INTEGER | NOT NULL DEFAULT 1 |
| `active` | INTEGER | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `referred_user_id` | TEXT | PRIMARY KEY REFERENCES users(id) |
| `referrer_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `code` | TEXT | NOT NULL |
| `source_app` | TEXT | NOT NULL CHECK (source_app IN ('marlins','learn')) |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `CHECK (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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `code` | TEXT | PRIMARY KEY,               -- chuỗi ngắn, phân biệt HOA/thường khi tra thì chuẩn hoá về hoa |
| `user_id` | TEXT | NOT NULL UNIQUE REFERENCES users(id),   -- UNIQUE: một người không được có hai mã |
| `created_at` | TEXT | NOT 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ột | Kiểu | Ràng buộc / ghi chú |
| --- | --- | --- |
| `referred_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `referrer_user_id` | TEXT | NOT NULL REFERENCES users(id) |
| `milestone` | TEXT | NOT NULL CHECK (milestone IN ('joined','learner_linked','diagnostic_done','first_lesson','active_week','course_enrolled','paid')) |
| `occurred_at` | TEXT | NOT NULL |
| `ref_id` | TEXT | — |
| `notified_at` | TEXT | — |
| `created_at` | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | *table constraint* | `PRIMARY 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)
