---
url: https://docs.nemo12.com/architecture/sdd-018-community-events.md
description: >-
  Sự kiện cộng đồng cho Marlin (SDD-018): cafe, webinar, buổi chia sẻ; bảng
  migration 0039, API /v1/events và đăng ký tham dự.
---

# SDD-018 — Sự kiện cộng đồng cho Marlin

Nemo12 gặp bố mẹ ngoài đời trước khi con vào học: buổi cafe 5 người, webinar ôn thi, buổi chia sẻ. Mảng này kế thừa trang Events đang chạy ở legacy `family.chuyenchon.com` (SRC-013) và là **mảng đầu tiên trong Marlins không nói về một đứa con cụ thể** — nên nó không đi qua `resolveLearnerAccess`, quyền ở đây là quyền trên **chỗ ngồi của chính mình**.

Phạm vi đợt này đúng ba việc chủ dự án nêu (SRC-122): **xem danh sách · xem chi tiết · đăng ký, và chỉ đăng ký được buổi sắp diễn ra**.

> **Sửa 2026-09-14 (SRC-711), trang chi tiết.** Bỏ khối "Thời gian" ở cột phải (hero ngay trên đã nói đủ ngày, giờ, độ dài — nhắc lại chỉ đẩy phần cần bấm xuống thấp); lịch trình đổi từ timeline sang accordion; đường vào **phòng của buổi** hiện cả với buổi đã diễn ra, vì ở đó còn câu hỏi và reflection để đọc lại; chân trang Marlins mang câu của riêng cộng đồng bố mẹ qua prop `tagline` của `SiteFooter` (bản canonical không đổi mặc định, nên learn và web giữ nguyên).
>
> **Sửa 2026-09-14 (SRC-711).** "Chỉ buổi sắp diễn ra" là luật của **đăng ký**, không phải của **danh sách**. Trang `/events` ban đầu gọi `scope=upcoming`, nên một buổi vừa diễn ra hôm qua làm cả trang rỗng kèm câu "chưa có buổi nào" — trong khi buổi ấy vẫn còn phòng, còn câu hỏi và reflection mà bố mẹ cần quay lại đọc. Nay trang gọi `scope=all` và tách hai nhóm *Sắp diễn ra* / *Đã diễn ra*; `can_register` vẫn do backend chốt nên buổi cũ hiện ra mà không mở cửa đăng ký. Ba mảng của legacy cố ý để lại: bình chọn câu hỏi trước buổi, wizard khai hồ sơ bắt buộc trước khi giữ chỗ, và bảng admin xem toàn bộ hồ sơ người đăng ký (§6).

## 1. Nguyên tắc

1. **Backend chốt "còn đăng ký được không"**, client chỉ hiển thị. Đồng hồ máy người dùng lệch là chuyện thường; nếu UI tự so ngày giờ thì hai người sẽ thấy hai câu trả lời khác nhau cho cùng một buổi.
2. **Một hàm duy nhất ra quyết định** (`registrationState`) và cả ba API cùng gọi nó, nên thẻ danh sách và trang chi tiết không bao giờ nói khác nhau. Ở legacy luật này nằm rải trong JSX, dẫn tới thẻ ghi "còn chỗ" trong khi trang chi tiết đã khoá.
3. **Khoá theo `user_id`, không theo email** (QG-004, RISK-001) — khác legacy vốn dùng email làm khoá chính của đăng ký.
4. **Không phát tán thông tin liên lạc của phụ huynh khác**: danh sách người đăng ký chỉ có tên hiển thị và ghi chú, không email/điện thoại kể cả đã che (§5).

## 2. Dữ liệu (migration `0039_community_events.sql`)

```text
community_events: id (slug, dùng thẳng trên URL), series, title, summary,
  goal, audience, agenda_json ([{t,label}]), notes (mỗi dòng một lưu ý),
  starts_at (ISO 8601 CÓ offset), duration_min, mode (offline|online),
  venue?, address?, capacity? (NULL = không giới hạn), fee?, fee_note?,
  status (active|cancelled), created_at
community_event_registrations: (event_id, user_id) PK, status (joined|cancelled),
  display_name, note?, created_at, updated_at
```

* **Không tách metadata/nội dung** như `labs` (SDD-010 §10): một sự kiện chỉ vài KB và danh sách sắp diễn ra không quá vài chục dòng.
* **`starts_at` bắt buộc mang offset**. Truy vấn dùng `datetime(starts_at) >= datetime('now')` để SQLite quy về UTC — so chuỗi trần sẽ lệch đúng bằng offset (7 tiếng với giờ Việt Nam).
* **Huỷ đăng ký giữ lại dòng** với `status='cancelled'` thay vì xoá: cần biết ai từng giữ chỗ rồi bỏ, và đăng ký lại không sinh dòng thứ hai.

## 3. API (`/v1/events`, module `workers/api/src/modules/events`)

| Route | Việc |
| --- | --- |
| `GET /v1/events?scope=upcoming\|all` | Danh sách; mặc định **chỉ buổi sắp diễn ra**, sớm nhất trước |
| `GET /v1/events/{eventId}` | Chi tiết: mục tiêu, đối tượng, lịch trình, lưu ý, chi phí, người đã đăng ký |
| `POST /v1/events/{eventId}/register` | Giữ chỗ, kèm ghi chú tuỳ chọn (vd giờ có mặt) |
| `POST /v1/events/{eventId}/unregister` | Bỏ chỗ của chính mình |

`registrationState` trả về `cancelled · past · remaining · full · registered · can_register`, và **`can_register` là cờ duy nhất UI đọc**. Đóng đăng ký khi: sự kiện đã huỷ · đã tới giờ bắt đầu · hết chỗ (với người chưa giữ chỗ) · đã giữ chỗ rồi.

Sự kiện đã diễn ra vẫn **mở được bằng link cũ** (chi tiết trả 200) — chỉ là không đăng ký được nữa. Link bố mẹ gửi cho nhau không chết sau buổi đó.

Lỗi theo taxonomy SDD-006 §11: `NOT_FOUND` cho id lạ; `CONFLICT` cho đã huỷ / đã diễn ra / hết chỗ, kèm câu tiếng Việt đọc là hiểu.

### 3b. Chỗ ngồi và cuộc đua giành ghế cuối

D1 không khoá hàng, nên đếm-rồi-ghi có khe hở kinh điển: hai người bấm cùng lúc vào chỗ cuối đều thấy còn trống. Cách xử lý (kế thừa legacy): tiền kiểm tra nhanh → ghi → **đếm lại; ai làm tràn thì tự trả chỗ** và nhận `CONFLICT`. Bấm đăng ký lần thứ hai chỉ cập nhật ghi chú, **không ăn thêm ghế**.

## 4. Domain events

`community.event.registered` và `community.event.unregistered` (v1, payload chỉ `{event_id, user_id}` — AS-03.3.4). Chưa có consumer nghiệp vụ: đây là nhật ký để đợt sau nối vào Context Engine ("bố mẹ chịu đi gặp trực tiếp" là tín hiệu mức độ đồng hành, SDD-002 §5b).

## 5. Quyền riêng tư (QG-008)

`user_id` **luôn lấy từ session**, không bao giờ nhận từ body — nên không có đường nào sửa hộ đăng ký của người khác. Danh sách người đăng ký trả về **tên hiển thị + ghi chú**, bỏ hẳn email/điện thoại mà legacy còn trả về dưới dạng đã che.

## 6. Màn hình (`apps/marlins/src/Events.tsx`)

Tab **Sự kiện** cạnh Gia đình và Khám phá; `#/events` danh sách, `#/events/{id}` chi tiết (mỗi buổi một URL để bố mẹ gửi link cho nhau).

Theo DS-001 §5b: thẻ chỉ có **khối ngày + tên + một dòng phụ** (giờ · nơi · số chỗ), cả thẻ bấm được nên không có link "Chi tiết" riêng, "đã giữ chỗ" là **chấm xanh** chứ không phải nhãn chữ. Trang chi tiết chia hai cột: Mục tiêu · Dành cho ai · Lịch trình · Trước khi đến bên trái; Thời gian · Địa điểm · Chi phí · Đăng ký · Đã đăng ký bên phải. Chữ trên hero để nguyên màu của `n12-hero` (trắng) — ghi đè `text-ocean-900` lên nền teal đậm là lỗi tương phản AS-09.3.1.

Khi không đăng ký được, màn hình **nói rõ vì sao** (đã huỷ / đã diễn ra / đủ người) thay vì để một cái nút xám.

## 6b. Tương tác trong buổi (SRC-711)

Đợt SRC-122 dừng ở "giữ chỗ". Chỉ đạo chủ dự án 2026-09-13 mở tiếp phần diễn ra **bên trong** buổi:
host tạo hoạt động, cả phòng trả lời, màn chiếu cập nhật trực tiếp.

### Bốn dạng hoạt động, một bảng

`event_activities.kind` nhận `question` · `poll` · `reflection_4f` · `reflection_ssc`. Hai khung
reflection **không có bảng riêng**: chúng là câu hỏi mở có nhiều ô, và ô nào nằm ở
`event_activity_answers.slot`. Tách bảng thì mở/đóng, thứ tự, quyền host phải viết lại bốn lần và
đến lúc lệch nhau sẽ không ai biết bên nào đúng.

| Dạng | Người trả lời làm gì | Trần `max_answers_per_person` đếm theo |
| --- | --- | --- |
| `question` | viết tự do, ủng hộ ý của nhau | mỗi người |
| `poll` | chọn một phương án host đưa ra | — (một người một phiếu, ép bằng khoá chính) |
| `reflection_4f` | Facts · Feelings · Findings · Future, trả lời **một vài ô cũng được** | mỗi người **mỗi ô** |
| `reflection_ssc` | Start · Stop · Continue, cùng cơ chế | mỗi người **mỗi ô** |

Trần reflection đếm theo Ô chứ không theo hoạt động: nếu không, ai viết kỹ ô Facts sẽ không còn
lượt nào cho ô Future.

### Ai là host

`community_events.host_user_id` (migration `0215`), hoặc role `admin`. Không route nào nhận host từ
body. Nhờ khoá theo từng buổi, mời một mentor chủ trì một buổi không phải cấp quyền admin toàn hệ
thống cho họ.

### Ba vòng quyền

1. `requireSession` — mọi route, kể cả WebSocket.
2. **Đã bấm Tham gia buổi** — chưa giữ chỗ thì `GET /activities` trả mảng rỗng (không phải 403: màn
   hình cần vẽ được lời mời, và một lỗi đỏ ở đó đọc như hỏng hóc).
3. **Host** — tạo/mở/đóng/xoá, Live Stage, Back-stage.

### Live Stage = Durable Object (`EVENT_STAGE`)

Durable Object đầu tiên của hệ thống. Vì sao không polling: 60 người trong phòng, polling 2 giây là
1.800 request mỗi phút mà phần lớn trả về "không có gì mới", và con số trên màn chiếu vẫn trễ.

Ranh giới trách nhiệm, phần dễ làm sai nhất:

| | Ở đâu |
| --- | --- |
| Nguồn sự thật (câu trả lời, phiếu) | **D1**. Mọi lượt ghi đi qua route HTTP → D1 → `notifyStage()` |
| Danh sách kết nối đang mở | DO, một instance mỗi buổi |
| Trạng thái nghiệp vụ trong DO | **Không có.** DO bị thu hồi giữa buổi thì không mất phiếu nào |

**Bản phát không mang dữ liệu riêng của ai** — không "tôi đã vote gì", không tên người viết. Một bản
phát đi tới mọi kết nối (kể cả máy người tham gia), nên bất cứ thứ gì riêng tư lọt vào đây là lọt
sang người khác. Hệ quả kiến trúc: màn người tham gia dùng bản phát như **tiếng chuông** rồi tự gọi
`GET /activities` của riêng mình; chỉ Live Stage vẽ thẳng từ bản phát.

Nhịp phát được gộp 80ms: 30 người cùng bấm trong một giây là một bản phát, không phải 30.

### Ba màn, ba việc khác nhau

| Màn | URL | Cho ai | Có tên người không |
| --- | --- | --- | --- |
| **Event Room** | `/events/{id}/room` | mọi người trong buổi | có — danh sách có mặt, chỉ mở cho người đã tham gia |
| **Live Stage** | `/events/{id}/live` | host, chiếu lên tường | **không** — chỉ con số |
| **Back-stage** | `/events/{id}/backstage` | host, trên máy mình | có — gồm cả ai **chưa** làm gì |

Tên người chỉ đi qua đường HTTP có cổng host, nên chúng **không thể** xuất hiện trên bản phát chung.
Đó là lý do "ai chưa bắt đầu" nằm ở Back-stage chứ không nằm trên màn chiếu.

Live Stage hiển thị ba con số cạnh nhau — *trong buổi · đã bắt đầu · đã trả lời*. Khoảng cách giữa
hai số sau là thứ host cần thấy: nó nói đề bài khó hay phòng đang bận.

### Khu hỏi host

Một `question` đặc biệt (`is_ask_host = 1`, id suy ra từ `event_id` nên dựng lười là idempotent
thật): **luôn mở, host không tạo và không xoá được**. Mọi người hỏi bất cứ lúc nào, ai cũng ủng hộ
hoặc bỏ ủng hộ được, câu nhiều ủng hộ nhất nổi lên đầu. Nó dùng chung toàn bộ đường code với câu hỏi
thường — không có nhánh riêng nào cho nó ngoài cờ đó.

### Chưa làm trong đợt này

| Của legacy | Vì sao để lại |
| --- | --- |
| Bình chọn 5 câu hỏi quan tâm nhất trước buổi | Ngoài phạm vi SRC-122; chỉ có nghĩa khi mỗi buổi đã có sẵn ngân hàng câu hỏi |
| Wizard bắt khai hồ sơ bố mẹ + con trước khi giữ chỗ | Nemo12 đã có hồ sơ gia đình thật (`families`, `learners`) — nên lấy từ đó, không hỏi lại; cần một vòng thiết kế riêng |
| Bảng admin xem toàn bộ hồ sơ người đăng ký | Thuộc `admin.nemo12.com`, và phải đi kèm audit log (QG-008) |

Quản lý nội dung sự kiện đợt này bằng seed script (`scripts/seed-events.sql`), giống cách Orca khởi động (SDD-016 §6).

## 6c. Brand, announcement và banner (SRC-1312, chỉ đạo 10.10.2026)

Màn Events của Dolphin có thêm ba thứ cho mỗi buổi:

* **Brand**: cột `community_events.brand` (migration 0341), chữ tự do. Ô nhập gợi ý NEMO IELTS,
  NEMO SAT, NEMO SPEAK, NEMO GRAMMAR, NEXT STEP từ hằng `BRAND_SUGGESTIONS` trong
  `apps/mentors/src/eventMedia.ts`, và người biên tập gõ được tên mới. Chủ dự án chốt: danh sách brand
  KHÔNG lưu trong database; thêm gợi ý là sửa hằng ấy. Để trống thì mọi thứ đề "NEMO12".
* **Ba dạng announcement**: tin nhắn ngắn (Zalo, Messenger), bài đăng mạng xã hội, email gửi phụ
  huynh. Dựng bằng mẫu từ đúng các ô đã lưu, không gọi AI: một câu bịa về giờ hay phí trong thư gửi
  bố mẹ đắt hơn nhiều so với một câu văn kém bay bổng. Ngày theo DD.MM.YYYY, giờ theo múi Việt Nam.
* **Banner**: chuyển sang Banner Studio, xem §6e.

Không thứ nào trong ba thứ trên được lưu: chúng dựng lại mỗi lần mở, nên sửa thông tin buổi là
announcement và ảnh đổi theo ngay. Test: `apps/mentors/src/eventMedia.test.ts`.

## 6d. Checklist vận hành của buổi (SRC-1312, chỉ đạo 10.10.2026)

Mỗi buổi có một checklist ba chặng (trước, trong, sau buổi) và ba cụm việc (truyền thông tới người
tham gia, khách mời và chuyên môn, logistics như địa điểm, Zoom, chỗ ngồi). Hai bảng (migration 0342):

* `event_checklist_tasks`: danh sách việc CHUNG cho mọi buổi, nạp sẵn 20 việc mặc định. Mentor thêm
  một việc là việc ấy hiện ở mọi buổi, để lần sau không phải tạo lại. Không có xoá (luật Dolphin);
  `archived_at` để ẩn.
* `event_checklist_marks`: tick và ghi chú của TỪNG buổi, kèm người sửa gần nhất. Không có dòng nghĩa
  là chưa làm.

API trong `workers/api/src/modules/events/checklist.ts`: GET và PUT `/v1/events/{eventId}/checklist`
cho nhân sự (mentor, staff, admin) và chủ buổi; POST `/v1/events/{eventId}/checklist/tasks` chỉ cho
nhân sự, vì việc thêm vào hiện ở buổi của người khác. PUT chỉ ghi ô có trong body, nên một người tick
lúc người khác đang gõ ghi chú không làm mất phần của ai. Màn: `apps/mentors/src/EventChecklist.tsx`,
tick thẳng, ghi chú theo luật đọc trước rồi Edit. Test: `checklist.test.ts`.

## 6e. Banner Studio của một buổi (SRC-1312, chỉ đạo 10.10.2026)

"Với một event thì cần làm một loạt banner." Ba trang có URL riêng trên Dolphin:
`/events/:id/banners` (loạt banner chia trước, trong, sau buổi), `/events/:id/banners/new` (chọn mẫu),
`/events/:id/banners/:bannerId` (xem chỉ đọc, tải PNG; Edit mới mở ô sửa, Save/Cancel tại chỗ).

* **30 mẫu** trong `apps/mentors/src/bannerTemplates.ts`: 14 trước buổi (thông báo, lưu ngày, đếm
  ngược 7 ngày và 1 ngày, hôm nay, khách mời, chương trình, lý do nên đến, câu hỏi lớn, chỗ cuối, miễn
  phí, địa điểm, online, thư mời), 7 trong buổi, 9 sau buổi. Mỗi mẫu là dữ liệu (bố cục, giọng màu,
  chữ mặc định có chỗ trống `{title}`, `{date}`...), một hàm vẽ chung cho mọi mẫu.
* **Sub-brand** đổi bảng màu, không đổi mẫu: 30 mẫu dùng được cho mọi sub-brand (NEMO SPEAK, NEMO
  WALK, NEMO IELTS, NEMO SAT...), kể cả tên gõ tay (màu băm từ tên).
* **Ba khổ**: vuông 1080×1080, dọc 1080×1350, ngang 1200×630. Chữ tự thu cỡ cho vừa khung, không bị cắt.
* **Bộ mặc định** khi buổi chưa có banner: 3 trước, 3 trong, 4 sau.
* **Màu nền**: sáu màu cố định của mỗi brand (Night, Deep, Brand, Soft, Light, Paper), chữ và màu
  nhấn đổi theo nền để đủ tương phản. **Hình trang trí** vẽ bằng thuật toán, 16 dạng (sóng, vòng tròn,
  hoa giấy, halftone...), ẩn/hiện được. **Nhãn góc phải** (kiểu "K2 · Khai giảng 27.10") có ở mọi mẫu.
  Ba lựa chọn này lưu trong `fields_json` với khoá `_bg`, `_deco`, `_decoOn`, không thêm cột.
* **Hay dùng lên đầu**: `GET /v1/banner-usage` đếm mẫu, hình trang trí và nền trên mọi banner chưa
  ẩn của mọi buổi; Dolphin xếp danh sách mẫu, hình trang trí và màu nền theo số đó, ba cái đầu có
  nhãn "Hay dùng". Không có bảng đếm riêng: đếm thẳng từ `event_banners`, nên không có hai nơi phải khớp.
* **Lưu**: bảng `event_banners` (migration 0343) giữ mẫu, khổ, sub-brand và CHỈ những ô người dùng
  đã sửa khác chữ mặc định. Ô chưa sửa bám theo thông tin buổi, nên đổi giờ buổi là banner đổi theo.
  Ảnh không lưu. Không có xoá; "Ẩn banner" đặt `archived_at`.
* API `workers/api/src/modules/events/banners.ts`, quyền như checklist. Test: `banners.test.ts`,
  `apps/mentors/src/bannerTemplates.test.ts`.

Không có skill hay thư viện sẵn nào trên GitHub làm đúng việc này (tra 10.10.2026): các thư viện
DOM sang ảnh (snapdom, html-to-image) cần thêm phụ thuộc và tải font qua mạng, còn skill
`canvas-design` của Anthropic chỉ làm ảnh lẻ. Canvas thuần đủ dùng và không thêm gói nào.

## Video của buổi gặp (SRC-720, chỉ đạo 2026-09-15)

### Yêu cầu, và điều đã nói rõ trước khi làm

Chủ dự án: mỗi buổi có chỗ gắn link YouTube dạng unlisted, learner xem ngay trên web và **không đi
sang YouTube**. Đã trình bày giới hạn trước khi làm, và chủ dự án chốt: **"giấu, không phải chặn"**.

Cái làm được là bịt mọi đường rời trang thông thường. Cái KHÔNG làm được, và không tài liệu nào của
Nemo12 được nói ngược lại: video unlisted là video ai có ID cũng xem được, mà ID phải nằm trong
trang thì trình phát mới chạy. Người mở công cụ dev đọc được nó trong mười giây.

**Hệ quả vận hành: đừng đặt vào đây video mà việc lọt ra ngoài gây hại thật.** Chỗ này dành cho bản
ghi buổi gặp. Muốn chặn thật thì phải rời YouTube sang dịch vụ có URL ký hạn (Cloudflare Stream).

### Lưu ID, không lưu URL

`community_events.youtube_video_id` (migration 0219) chịu `CHECK` đúng dạng ID: 11 ký tự, chữ, số,
gạch dưới, gạch nối. Lý do không phải gọn gàng mà là an toàn: cột này được ghép thẳng vào `src` của
một `<iframe>`. Nhận URL tự do nghĩa là mỗi chỗ render phải tự nhớ kiểm tra lại.

`youtubeVideoId()` phân tích bằng `URL` rồi đối chiếu host theo danh sách trắng, **không bóc bằng
regex**: cách regex nhận nhầm `https://evil.example/youtube.com/watch?v=…` vì chuỗi có khớp trong khi
host hoàn toàn khác. Test giữ đúng ca đó.

Server trả về **địa chỉ nhúng dựng sẵn**, không trả ID trần: luật dựng địa chỉ vì thế chỉ có một bản.

### Ai gắn được

Chỉ `host_user_id` của buổi, hoặc admin, qua `PUT /v1/events/{id}/video`. Dùng lại đúng `isHost` đã
là cổng của mọi hoạt động trong buổi thay vì dựng một luật quyền thứ hai. Nhận URL ở dạng người ta
thật sự dán vào; server lo bóc ID. Chuỗi rỗng là cách gỡ video.

### Hai lớp phủ, kích thước ĐO từ trình phát thật

Tham số nhúng: `youtube-nocookie.com` + `rel=0` + `modestbranding=1` + `playsinline=1` + `fs=0` +
`iv_load_policy=3`. Bỏ `fs` vì ở chế độ toàn màn hình YouTube hiện thanh tiêu đề rõ hơn.

Còn lại hai chỗ bấm được dẫn ra ngoài, và kích thước lớp phủ lấy từ ảnh chụp trình phát thật chạy
trên một origin thật (2026-09-15), không ước lượng:

| Chỗ | Đo được | Lớp phủ |
| --- | --- | --- |
| Tiêu đề + tên kênh, mép trên | cao ~18% khung | `top-0`, cao 20% |
| Nút "Watch on YouTube", góc dưới phải | rộng ~33%, cao ~14% | `bottom-0 right-0`, 38% × 20% |

Bản đầu chỉ có một dải trên cao 56px; ảnh chụp cho thấy nút "Watch on YouTube" nằm **nguyên vẹn**
bên dưới, tức là cánh cửa chính vẫn mở trong khi bình luận trong code nói đã bịt. Đây là lý do phải
mở trình phát thật ra nhìn thay vì tin vào một con số gõ ra từ trí nhớ.

Dùng phần trăm chứ không pixel để khung thu nhỏ trên điện thoại thì lớp phủ vẫn đúng chỗ. Góc dưới
trái còn một nút tròn nhỏ chưa che, vì che nó sẽ đụng vào hàng điều khiển phát.

## Trace

| REQ | Section |
| --- | --- |
| REQ-PAR-16 (danh sách sự kiện sắp diễn ra) | §2, §3, §6 |
| REQ-PAR-16 (chi tiết từng sự kiện, mỗi buổi một URL) | §3, §6 |
| REQ-PAR-16 (đăng ký/huỷ, chỉ buổi sắp diễn ra, tôn trọng số chỗ) | §3, §3b |
| REQ-PAR-16 (không lộ liên lạc của phụ huynh khác) | §5 |
| REQ-PAR-16 (tương tác trong buổi: câu hỏi, poll, reflection, Live Stage) | §6b |

Liên quan: SDD-001 §2 (module boundary), SDD-006 §11 (error taxonomy), [legacy-migration](../legacy/legacy-migration.md) (nguồn `portal_events` của family.chuyenchon.com), SDD-002 §5b (nơi domain event sẽ nối vào đợt sau), DS-001 §5b (luật tối giản UI).
