---
url: >-
  https://docs.nemo12.com/architecture/sdd-023-family-workspace/contacts-and-home.md
description: >-
  Lịch sử tiếp xúc và phát hiện im lặng, trang chủ cổng sáu cụm gập lại, ai qua
  Cloudflare Access thì là mentor.
---

# SDD-023 · Lịch sử tiếp xúc, trang chủ cổng và quyền mentor qua Access

Một phần của [SDD-023](./index.md). Lịch sử tiếp xúc và phát hiện im lặng, trang chủ cổng sáu cụm gập lại, ai qua Cloudflare Access thì là mentor.

## 25. Lịch sử tiếp xúc, và việc phát hiện im lặng ([SRC-691](../../intake.md))

Chỉ đạo 2026-09-08: cần biết bố mẹ nào **đã gặp trực tiếp**, gặp mấy lần, lần gần nhất cách đây
bao lâu, và lần trao đổi qua chat gần nhất là khi nào. Lý do nêu kèm mới là thứ định hình thiết
kế: *"khi ko trao đổi với phụ huynh, thì dần dần phụ huynh và con của họ nghỉ học hết."*

Câu đó biến việc này từ **một sổ ghi chép** thành **một cái cảnh báo**. Một cái sổ trả lời "chuyện
gì đã xảy ra"; nó được đọc khi người ta đã nghĩ tới việc mở nó ra. Nhưng gia đình sắp nghỉ học
đúng là gia đình không ai còn nghĩ tới — nên cái sổ ấy sẽ không được mở đúng lúc nó có ích nhất.
Vì vậy đợt này làm hai phần, và phần thứ hai mới là phần trả nợ cho lý do trên.

### 25.1 Hai cột, không phải một bảng mới

`family_notes` nhận thêm `channel` và `happened_on` (migration `0208`), thay vì dựng bảng
`family_meetings` riêng. Một lần gặp **đã là** một ghi chú: mentor gặp phụ huynh xong thì viết lại
nội dung, và tag `event` đã mang nghĩa "có chuyện xảy ra". Tách ra bảng riêng nghĩa là mentor phải
ghi hai chỗ cho một lần gặp — và chỗ thứ hai là chỗ sẽ bị quên. Đây đúng là cách phần lớn CRM chết
dữ liệu: cái log tách khỏi nội dung, rồi không ai điền log.

`channel` **không phải một tag**, dù danh sách tag đã có sẵn và thêm vào đó thì rẻ hơn. Tag là
*nghĩa* của nội dung (pain · jtbd · goal…); kênh là *đường* mà nội dung đó tới. Hai trục vuông
góc — một ghi chú vừa là `pain` vừa xảy ra khi gặp trực tiếp. Nhét kênh vào tag thì danh sách đóng
phình gấp đôi, và mọi câu đếm phải quét `tags_json LIKE '%…%'`, đúng thứ Audit #005 đã phải gỡ ra
một lần vì nó không dùng được chỉ mục.

`happened_on` tách khỏi `created_at` vì hai ngày đó **khác nhau trong đời thật**: mentor gặp thứ
Bảy, tối Thứ Ba mới ngồi viết. Đếm theo `created_at` thì "gặp cách đây bao lâu" sai đúng bằng
khoảng mentor còn nợ việc ghi chép — và với một chỉ số dùng để phát hiện im lặng, sai theo hướng
**lạc quan** là kiểu sai tệ nhất: nó giấu đúng những nhà cần gọi.

Năm kênh: `in_person` · `video` · `call` · `chat` · `email`. `video` đứng riêng khỏi cả
`in_person` lẫn `chat` có chủ đích — chỉ đạo hỏi riêng về "gặp trực tiếp" nên số đếm đó không được
lẫn một cuộc Meet vào, nhưng gộp video vào nhắn tin cũng sai: ngồi nhìn mặt nhau nửa tiếng không
cùng hạng với vài câu nhắn.

### 25.2 Thang im lặng, và vì sao "chưa bao giờ" khác "đã lâu"

`silenceLevel()` xếp bốn mức: `ok` → `watch` (>30 ngày) → `alarm` (>60 ngày), và `never` đứng
riêng. Hai ngưỡng **là phỏng đoán** — chưa có dữ liệu bỏ học nào của Nemo12 được đối chiếu để rút
ra chúng, và điều đó được ghi thẳng trong `contactLog.ts` để không ai đọc chúng như một kết luận.
Khi đã đủ số ca nghỉ để đối chiếu thì phải đo lại: nếu phần lớn ca nghỉ có khoảng im lặng cuối
cùng ngắn hơn 60 ngày, ngưỡng này đang báo động **quá muộn**, tức là vô dụng đúng lúc cần nhất.

`never` tách khỏi `alarm` vì **việc phải làm khác nhau**: `alarm` là nối lại một quan hệ đã có,
`never` là bắt đầu từ đầu.

Một luật nhỏ nhưng quyết định việc màn này có được dùng hay không: **nhà vừa được tạo, chưa tiếp
xúc lần nào, KHÔNG phải báo động** — nó là nhà chưa ai kịp làm gì. Không phân biệt hai thứ đó thì
mỗi lần nhập liệu đẻ ra một báo động giả, và một danh sách cảnh báo đầy báo động giả sẽ bị bỏ qua
**toàn bộ**, kể cả những dòng đúng. Vì vậy `silenceLevel()` nhận thêm tuổi của gia đình.

### 25.3 Lọc trong SQL, không lọc mảng kết quả

Ô lọc "lâu rồi không ai trao đổi" chạy trong `WHERE`, không lọc mảng sau khi đã `LIMIT 200`. Lọc
sau thì nhà im lặng nằm ở vị trí 201 không bao giờ hiện ra — mà đó đúng là nhà cần tìm nhất: im
lặng lâu đồng nghĩa `updated_at` cũ, tức là bị chính câu `ORDER BY` đẩy xuống cuối. Điều kiện SQL
phải khớp đúng `silenceLevel() !== "ok"`; hai chỗ lệch nhau thì danh sách lọc ra một tập còn phù
hiệu trên từng dòng nói một tập khác, và không ai biết bên nào đúng.

Cùng lý do "đếm phải đếm đủ": ở màn chi tiết, tóm tắt lấy từ **truy vấn riêng**, không suy ra từ
danh sách ghi chú đang hiển thị — danh sách đó có `LIMIT 200`, nên với một nhà theo lâu năm thì
"đã gặp mấy lần" sẽ đếm thiếu mà không có dấu hiệu nào.

### 25.4 Vẽ ở đâu — hai màn, hai luật khác nhau

| Màn | Hiện gì | Vì sao |
| --- | --- | --- |
| Danh sách | **chỉ** phù hiệu im lặng, khi khác `ok` | Nhà đang liên lạc bình thường là đa số; một dấu hiệu hiện ở mọi dòng thì không phân biệt được gì (luật §Ký hiệu, SRC-649) |
| Chi tiết | luôn hiện đủ: gặp mấy lần · gần nhất · chat gần nhất | Đây là nơi người ta vào để biết về **đúng nhà này** |

Phù hiệu dùng **chữ chứ không dùng icon**, dù chỉ đạo chung là ưu tiên icon: "47" là thông tin, mà
không hình vẽ nào nói được một con số — icon ở đây chỉ nói được "có gì đó", đúng loại dấu hiệu vô
dụng mà SRC-649 vừa dọn đi.

Ô lọc mặc định **tắt**. Bật sẵn thì màn chính chỉ còn các nhà đang có vấn đề, và mentor mất đường
vào những nhà mình vẫn đang chăm bình thường.

### 25.5 Giới hạn, nói thẳng

**"Chat" là số liệu ghi tay.** Nemo12 không có hệ nhắn tin hai chiều — trao đổi thật diễn ra trên
Zalo/Messenger, ngoài hệ thống. Hệ email hiện có (`email_sends`, `email_opens`) là **một chiều**:
mình gửi đi, và một bản tin đã gửi không phải một cuộc trao đổi. Nghĩa là con số "chat gần nhất"
chỉ đúng bằng mức mentor chịu ghi lại, và các lần gặp trực tiếp — vốn hiếm và đáng nhớ — sẽ được
ghi đầy đủ hơn hẳn các lần nhắn tin. Đọc chỉ số này mà quên điều đó sẽ dẫn tới kết luận sai theo
hướng bi quan ở cột chat. Nối Zalo để đếm tự động là việc riêng, chưa làm.

## 26. Trang chủ của cổng: sáu cụm gập lại ([SRC-1176](../../intake.md))

Chỉ đạo 01.10.2026: *"Từ trang HOME của dolphin.nemo12.com, vào đó thì nhìn thấy 6 cụm menu-item.
Mỗi cụm, bên trong chúng có vài menu items nhỏ hơn. Mặc định thì có 6 card được collapsed."*

### 26.1 Cổng này chưa từng có trang chủ

Trước đợt này `/` **đưa thẳng về danh sách gia đình**. Đó là lựa chọn đúng của SRC-585, khi cổng
chỉ có năm khu và danh sách gia đình đúng là việc chính. Nhưng cổng nay có **mười ba khu**, và cái
dropdown ở header (SRC-649) đã thành một danh sách mười ba dòng phẳng.

Mười ba dòng phẳng không có cấu trúc thì người mới phải đọc hết mới biết cổng này làm được những
gì. Sáu cái tên thì đọc được trong một nhịp.

Dropdown **vẫn ở đó** và vẫn đúng việc của nó: chuyển khu khi đã biết mình muốn đi đâu. Trang chủ
trả lời câu hỏi khác — *ở đây có những gì*.

### 26.2 Sáu cụm chia theo CÂU HỎI, không theo loại dữ liệu

Mentor không mở cổng này để "xem bảng families"; họ mở nó với một câu hỏi trong đầu. Nên tên cụm là
câu hỏi ấy, và hai khu nằm cùng cụm khi chúng trả lời hai nửa của cùng một việc.

| Cụm | Khu bên trong | Vì sao chúng đi cùng nhau |
| --- | --- | --- |
| Learners and families | Families · Desk | Cùng một đối tượng, hai đường vào: từ một gia đình, và từ việc hôm nay phải chạm vào ai |
| Teaching | Calendar · Slots · Live Session | Ba mốc của cùng một buổi dạy: buổi nào SẼ có, buổi nào ĐÃ có, và sáu mươi phút đang diễn ra |
| What we teach | Curriculum · Events | Nội dung và cách tổ chức nội dung |
| Reviewing the whole cohort | Dashboards · Where learners are | Cả hai trả lời "cả nhóm đang thế nào", khác nhau ở chỗ cái sau trả lời bằng ĐỊA ĐIỂM |
| People and schools | Team · Schools | Sổ tra cứu về người và tổ chức bên ngoài một gia đình |
| What the public sees | Testimonials · Albums | Sửa ở đây là đổi thứ **người ngoài đang xem** trên nemo12.com |

**Đường may yếu nhất là "People and schools"**, và nói ra thì tốt hơn giả vờ nó sạch: Team là
mentor của mình, Schools là các trường mình theo dõi. Chúng chung nhau đúng một điều, và không khu
nào đủ lớn để đứng riêng một thẻ. Khi một trong hai mọc thêm vài khu nữa thì tách ra.

### 26.3 NHÓM là nguồn, danh sách phẳng suy ra từ nó

`apps/mentors/src/sections.ts` khai `GROUPS`; `SECTIONS` là `GROUPS.flatMap(g => g.items)`.

Giữ hai danh sách song song sẽ mở ra đúng một cách hỏng: một ngày nào đó một khu mới được thêm vào
định tuyến mà **không có mặt ở trang chủ** — nó không sai ở đâu cả, nó chỉ không ai tìm thấy. Loại
hỏng im lặng, không màn hình nào báo gì. Nay thêm một khu thì **bắt buộc** phải chọn cụm cho nó, vì
không có chỗ nào khác để khai. `sections.test.ts` chốt lại điều đó.

### 26.4 Ba quyết định nhỏ

**Accordion của shadcn, không tự viết.** Gập/mở là chỗ rất dễ hỏng ở phần không nhìn thấy:
`aria-expanded`, liên kết nút với vùng nội dung, mũi tên lên xuống, focus rơi vào vùng đang ẩn.
Radix làm sẵn cả bốn — đúng phân vai DS-001 §5d: shadcn lo HÀNH VI, token lo hình thức.

**Mở được nhiều cụm cùng lúc.** Accordion một-lúc-một-cái sẽ tự đóng cụm đang mở khi bấm cụm khác,
mà đây là trang dùng để SO SÁNH xem nên đi đâu; tự đóng là việc người dùng không yêu cầu.

**KHÔNG nhớ cụm nào từng mở.** Chỉ đạo nói rõ "mặc định thì có 6 card được collapsed"; nhớ lại
trạng thái cũ sẽ phá đúng câu đó ở mọi lần vào sau. Cái giá là mỗi lần vào là một lần bấm.

### 26.5 Hai thứ suýt hỏng âm thầm, bắt được vì mở trình duyệt ra xem

1. **Thẻ gập bị kéo cao bằng thẻ đang mở.** Mặc định của CSS grid là `stretch`, nên một thẻ còn
   gập nằm cạnh một thẻ đang mở biến thành một ô trống to tướng — trông đúng như một thẻ hỏng.
   `items-start` chữa. Typecheck, lint và test đều không nói gì về chuyện này.
2. **Hai lối tắt của SRC-892 suýt bị bỏ lại.** Chỉ đạo 20.09.2026 nói *"từ trang HOME cần có link
   tới trang này"*, và lúc ấy "trang chủ" chính là danh sách gia đình, nên `HomeLinks` nằm ở đó.
   Dựng một trang chủ thật mà không chuyển chúng sang thì link vẫn còn, chỉ là **không còn ở trang
   chủ nữa** — chỉ đạo cũ bị phá mà không có dấu hiệu nào. Nay chúng đứng TRÊN sáu cụm, không nằm
   trong cụm nào: hai việc thường xuyên nhất của cả cổng không nên nằm sau một cú bấm mở cụm.

### 26.6 Đường lạ nay về trang chủ, không về Families

`sectionFor()` trả `null` cho cả `/` lẫn đường dẫn không khớp khu nào. Trước đợt này đường lạ rơi về
Families, nên một URL gõ sai hiện ra một danh sách trông hoàn toàn bình thường và người dùng **không
hề biết** mình đã không tới được chỗ mình nhắm.

Phép khớp cũng chặt lại: `path === s.path || path.startsWith(s.path + "/")` thay cho `startsWith`
suông — `startsWith("/team")` nuốt cả `/teamwork`.

### 26.7 Icon và một dòng mô tả cho mỗi cụm ([SRC-1177](../../intake.md))

Thẻ gập chỉ còn một cái tên, mà một cái tên như "What we teach" không nói được bên trong có gì. Mỗi cụm
nay có icon và **một** dòng mô tả câu hỏi mà cụm ấy trả lời, khai cùng chỗ với cụm trong
`apps/mentors/src/sections.ts` (trường `icon`, `blurb`). Đây là chữ thêm vào, trái chiều luật tối giản
(SRC-048), nhưng nó trả lại nhiều hơn chỗ nó chiếm: không có nó thì cách duy nhất để biết bên trong là
bấm thử từng cụm.

Icon là emoji, không phải N12Art: DS-001 §2c cấm emoji làm icon chính của sản phẩm mà learner và phụ
huynh nhìn thấy, còn đây là cổng nội bộ và `HomeLinks` đã dùng đúng lối này từ SRC-892.

Kèm một lỗi có sẵn: `cardClass` nay mang `flex flex-col`, nên hai thẻ lối tắt nối thêm `flex
items-center` vào cuối chuỗi KHÔNG đè được — thẻ xếp theo cột, icon rơi xuống dòng riêng và bị căn
giữa. Sửa bằng `flex-row` qua `cn()`.

## 27. Ai qua Cloudflare Access của dolphin thì là mentor ([SRC-1262](../../intake.md))

Chỉ đạo 06.10.2026: *"Mọi tài khoản vào được dolphin.nemo12.com qua Cloudflare Access, thì đều có Role
là mentor. Email nào cũng là mentor hết, miễn là vượt qua Cloudflare Access."*

Trước đây có hai danh sách phải giữ khớp tay: policy của Access (ai được mở trang) và
`role_assignments` (vào rồi được xem gì). Người mới được thêm vào Access mà quên gán vai thì đăng
nhập xong gặp một màn rỗng, trông như lỗi hệ thống. Nay danh sách duy nhất là **policy của Access**;
vai mentor đi theo nó.

**Đường đi.** Sau khi đăng nhập Google, trang gọi `POST /auth/mentor-grant` trên chính
dolphin.nemo12.com. Worker của dolphin (`apps/mentors/worker.ts`, mới) đứng sau Access nên cầm được
header `Cf-Access-Jwt-Assertion`; nó chuyển header ấy cùng cookie phiên xuống
`POST /v1/auth/mentor-grant`. Cùng khuôn với admin.nemo12.com (SRC-129).

**Ba điều kiện ở API** (`workers/api/src/modules/auth/mentorGrant.ts`), thiếu một là không cấp:

1. Có phiên đăng nhập hợp lệ.
2. JWT của Access có chữ ký hợp lệ và cấp cho **đúng app Nemo12 Dolphin** (`ACCESS_AUD_DOLPHIN`,
   khác AUD của admin). Test chứng minh JWT của admin không lấy được vai mentor, và JWT của dolphin
   không mở được cửa admin.
3. Email trong JWT **trùng** email của tài khoản đang đăng nhập. Không có điều này thì một người qua
   Access bằng email A rồi đăng nhập Google bằng email B sẽ cấp vai cho B.

**Vì sao không dựa vào header Origin:** api.nemo12.com không nằm sau Access, và Origin thì ai gửi
request ngoài trình duyệt cũng tự đặt được. Mentor xem được dữ liệu của mọi học viên, nên vai này
phải đứng trên một thứ có chữ ký.

Người đã là mentor thì endpoint trả về ngay, không kiểm JWT và không tính vào giới hạn đăng nhập.
Mỗi lần cấp hoặc từ chối vì lệch email đều ghi nhật ký truy cập.

**Không tự gỡ vai.** Bỏ một email khỏi policy Access là người ấy không mở được dolphin nữa, nhưng vai
`mentor` trong ứng dụng vẫn còn (và vẫn đọc được API nếu còn phiên). Muốn thu hồi hẳn thì gỡ vai
bằng `seed-data`. Đồng bộ chiều gỡ là việc riêng, chưa làm.

## 28. Thanh bên trái và breadcrumb ([SRC-1320](../../intake.md))

Chủ dự án 10.10.2026: tìm skill UI/UX cho admin trên GitHub rồi chỉnh thanh điều hướng và các trang
Dolphin theo tiêu chuẩn mới, chia đợt. Tiêu chuẩn ghi ở `.claude/skills/dolphin-ui/SKILL.md`, chắt từ
skill `shadcn` (shadcn-ui/ui) và `web-design-guidelines` (vercel-labs); hai skill ấy không cài vào repo
vì một cái tự chạy `npx shadcn@latest` (trái luật ghim số), một cái tải luật từ mạng mỗi lần chạy.

Đợt 1 (đợt này) là khung trang:

* **Thanh bên trái** `apps/mentors/src/Sidebar.tsx` từ `lg` trở lên, thay dropdown của §SRC-649. Sáu cụm
  của §26 làm tiêu đề nhóm; gập được còn một cột chữ tắt (hai chữ đầu tên khu), nhớ lựa chọn trong
  trình duyệt. Dưới `lg` vẫn là dropdown, vì không đủ chỗ cho một cột cố định.
* **Breadcrumb** Home › Cụm › Khu trên đầu mọi trang con; mục cuối không phải link.
* **Mỗi cụm mặc định đóng** (chủ dự án 10.10.2026), dựng bằng `Accordion` của bộ shadcn chung; bấm
  vào cụm mới mở. Riêng cụm chứa trang đang mở thì mở sẵn, để mục đang chọn không bị giấu.
* **Icon là Heroicons** (`@heroicons/react` 2.2.0, bản outline 24px, chủ dự án 10.10.2026), thay emoji
  ở Home, sáu cụm và nút thu gọn. Ánh xạ cụm sang icon nằm ở `GROUP_ICONS` trong `Sidebar.tsx`.

Các đợt sau làm từng nhóm trang theo bốn loại của skill (danh sách, xem, sửa tại chỗ, tạo mới),
mỗi đợt vài trang, để không xung đột với các phiên khác đang sửa Dolphin. Trang Events (SRC-1312) là
trang mẫu đầu tiên đã theo đúng bốn loại.

Đợt 2 (cụm People and schools, phần Team): `/team` mỗi thẻ là link thật; "Add person" mở trang
riêng `/team/new`, tạo xong chuyển tới hồ sơ vừa tạo; `/team/:id` chỉ đọc, nút Edit ở góc trên phải,
Save/Cancel thay đúng chỗ ấy; bỏ nút Delete hồ sơ (muốn rút thì Unpublish). Schools để đợt sau.

**Rà design system so với nemo12.com (10.10.2026).** Chủ dự án chốt: design system của nemo12.com là
chuẩn. Kết quả rà: Dolphin khớp MỘT PHẦN. Cùng token màu, cùng file CSS nền, cùng bản sao component
shadcn (accordion, badge, button, card, dialog, input, label, table, tabs, textarea trùng canonical;
`select` là fork có lý do ghi ở `copies.json`). Lệch ở lớp trên:

1. Chưa nạp `soft.css` và chưa gắn `theme-soft n12-v1` ở gốc trang, nên bo góc, đổ bóng và kiểu nút
   khác www. **Đã sửa ở đợt này**: `apps/mentors/src/index.css` nạp `soft.css`, `index.html` gắn hai
   lớp lên body, nền `bg-sand` riêng đổi sang token `bg-background`.
2. Còn khoảng 150 chỗ dùng lớp cũ `n12-*` (30 chỗ trong nhóm cổng SRC-570 đếm, phần còn lại như
   `n12-section-title`, `n12-empty`, `n12-chip`, `n12-tab` cổng chưa đếm), 73 thẻ `button` và 21 thẻ
   `input` trần thay vì component, 36 emoji làm icon, `text-ocean-900` 60 chỗ.
3. Header là bản dựng riêng, không phải `SiteNav` của www.

Việc tiếp, theo thứ tự: chuyển lớp `n12-*` và thẻ trần sang component theo từng nhóm trang (hạ trần
SRC-570 mỗi đợt); mở rộng cổng SRC-570 để đếm cả các lớp chưa đếm; thay emoji bằng icon chung; đưa
phần `.theme-www` dùng chung được vào `packages/design-system`; thêm cổng bắt mọi cổng nội bộ nạp
`soft.css` và gắn lớp theme ở gốc.

## Lịch sử quyết định

Trang này chỉ chứa đặc tả đang hiệu lực; các vòng sửa của mảng này nằm ở những trang khác trong cùng thư mục, xem [mục lục](./index.md).
