Skip to content

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. 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) ​

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ànHiện gìVì sao
Danh sáchchỉ phù hiệu im lặng, khi khác okNhà đ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ếtluô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) ​

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ụmKhu bên trongVì sao chúng đi cùng nhau
Learners and familiesFamilies · DeskCù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
TeachingCalendar · Slots · Live SessionBa 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 teachCurriculum · EventsNội dung và cách tổ chức nội dung
Reviewing the whole cohortDashboards · Where learners areCả 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 schoolsTeam · SchoolsSổ tra cứu về người và tổ chức bên ngoài một gia đình
What the public seesTestimonials · AlbumsSử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) ​

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) ​

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) ​

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.