Skip to content

SDD-023 · Nguyên tắc, mô hình gia đình, ảnh, quyền và API ​

Một phần của SDD-023. Nguyên tắc, gia đình, người trong nhà, nối tài khoản, ghi chú, ảnh, quyền và nhật ký, API, danh sách theo gia đình, giao diện tiếng Anh.

1. Nguyên tắc ​

  1. Không sinh tài khoản ma (Q-142). Người chưa đăng nhập sống trong family_contacts, không trong users.
  2. Máy không tự gộp hai hồ sơ người (Q-143). Máy chỉ được phép gợi ý; nối là một hành động có người bấm và có tên người bấm.
  3. Không đè lời khai của chính chủ (Q-144). Tên và ảnh do người ta tự đặt thì giữ nguyên; mentor ghi vào lớp riêng, hai lớp hiện cạnh nhau.
  4. Ảnh trẻ em không có đường tự động ra trang công khai (Q-146).
  5. Lưu trữ, không xoá (Q-150). Xoá vĩnh viễn thuộc luồng quyền riêng tư trẻ em (REQ-SEC-04), không phải một nút dùng hằng ngày.
  6. Đọc rộng thì phải ghi lại (Q-141). Mọi lượt mở hồ sơ một nhà đều để lại một dòng audit_log.

2. Gia đình (REQ-MEN-11) ​

families đã có từ migration 0001 và đang được learners.family_id trỏ tới, nên đợt này mở rộng tại chỗ, không dựng bảng thứ hai — cùng lý do đã chốt ở Q-135 cho target_schools.

text
families (0001):     id · name · created_by → users(id) · created_at
families (0078 thêm):
  source              -- self | mentor : nhà này do ai dựng lên
  status              -- active | archived
  updated_at

created_by vốn NOT NULL REFERENCES users(id) — mentor là một users nên tạo được ngay, không phải nới ràng buộc nào. source='mentor' không phải nhãn trang trí: nó là câu trả lời cho "những gì ghi ở đây là do ai kể?", và giao diện dùng nó để không hiển thị dữ liệu mentor tự khai như thể chính gia đình đã xác nhận.

Con thì dùng thẳng learners với status='invited' (Q-149) — hồ sơ sống độc lập với việc con đã vào hệ hay chưa, đúng như WF-19 §4 đã dựng cho luồng bố mẹ tự khai. Mentor tạo con không đẻ ra khái niệm mới nào.

3. Người trong nhà (REQ-MEN-12) ​

text
family_contacts: id · family_id → families(id)
  · role                -- father | mother | guardian | other
  · full_name? · phone? · zalo? · email?
  · occupation? · workplace?              -- Q-148
  · address? · district? · province?      -- Q-148
  · note?
  · user_id? → users(id)                  -- NULL cho tới khi được nối
  · linked_at? · linked_by? → users(id)
  · created_by → users(id) · source(mentor|self)
  · status(active|archived) · display_order · timestamps

Mọi trường mô tả đều cho phép NULL, kể cả full_name. Chỉ đạo nói thẳng: "Có thể thiếu thông tin này thông tin kia, thì vẫn tạo được các family." Một mentor vừa gặp phụ huynh ở buổi offline có khi chỉ nhớ "mẹ, số cuối 88, con học chuyên Toán" — bắt điền đủ mới lưu được nghĩa là thông tin đó không bao giờ vào hệ, nó nằm lại trong tin nhắn Zalo của mentor. Ràng buộc duy nhất: một hàng phải có ít nhất một trong {full_name, phone, email} thì mới có gì để nhận ra người đó về sau.

Tên gọi ở nhà (learners.nickname, migration 0083, SRC-606). Không phải trường trang trí: mentor gọi điện cho gia đình cần biết cả nhà gọi bé là gì. Gọi đúng tên ở nhà là chi tiết nhỏ nhưng nói ngay rằng mình có nghe, còn gọi sai thì nói ngay điều ngược lại.

Vì thế nó hiện cạnh tên đầy đủ, không giấu trong tooltip; và trên chip con ở danh sách gia đình thì tên gọi ở nhà được ưu tiên vì đó là cách cả nhà thật sự gọi. Sửa được ngay tại chỗ.

role cố ý chỉ có bốn giá trị và không có child: con nằm ở learners. Hai chỗ chứa trẻ em là hai chỗ để quên đồng bộ.

Thẻ gia đình ở danh sách: ảnh trên, tên dưới (SRC-608) ​

Ba nhóm người trong một nhà — bố mẹ · các con · mentor đang chăm — đều hiện dạng ảnh trên, tên ngay dưới. Đây là màn mentor lướt qua hàng chục nhà, và một khuôn mặt nhận ra nhanh hơn một dòng chữ.

Ảnh lấy theo thứ tự ảnh riêng → avatar của tài khoản đã nối → chữ cái đầu. Cố ý không để ô trống: ô trống trông như ảnh hỏng, còn chữ cái vẫn phân biệt được người này với người kia.

Thẻ không còn nút nào. Nhận và bỏ chăm sóc chuyển về đầu trang chi tiết — nhưng không nằm sau công tắc sửa, vì đó không phải sửa dữ liệu gia đình mà là việc riêng của mentor, và hai ô lọc ở §11 đọc thẳng từ nó.

4. Nối vào tài khoản thật (REQ-MEN-12) ​

Khi tài khoản thật đăng nhập, hệ gợi ý, mentor duyệt (Q-143). Gợi ý đến từ hai nguồn, và mỗi gợi ý nói rõ nó dựa vào đâu:

Nguồn gợi ýĐộ chắcCâu hiện ra
family_contacts.email khớp users.emailcao"trùng email"
Người đã là family_members hoặc guardians của chính nhà này mà chưa nối vào liên hệ nàotrung bình"đã ở trong nhà này nhưng chưa gắn với ai"

Không có gợi ý theo tên gần giống. Tên tiếng Việt trùng nhau rất nhiều; một gợi ý sai ở đây không phải phiền phức nhỏ mà là ghép hồ sơ nhà này vào nhà khác.

Nối là POST .../link với user_id tường minh, ghi linked_at + linked_by. Bỏ nối được, và bỏ nối không xoá dữ liệu mentor đã ghi — nó chỉ gỡ mối quan hệ, vì gỡ nhầm mà mất luôn số điện thoại thì lần sau không ai dám bấm.

Khi người được nối đã thuộc một gia đình khác (điển hình: phụ huynh tự đăng ký trên marlins đã sinh ra một families của riêng họ), hệ vẫn nối và cảnh báo, chứ không gộp hai nhà (✍️ Q-151). Gộp hai gia đình là thao tác chuyển learners, guardians, evidence và mục tiêu thi sang khoá mới — một đợt riêng, không phải hiệu ứng phụ của một cú bấm "nối".

Sau khi nối, mentor vẫn không đè được hồ sơ chính chủ (Q-144). API trả về hai lớp tách bạch và giao diện bày cạnh nhau:

text
{ account: { display_name, email, avatar_url },   // users tự khai, chỉ đọc
  mentor:  { full_name, phone, address, ... } }   // family_contacts, mentor sửa

5. Ghi chú (REQ-MEN-13) ​

Dùng lại bảng interactions (SDD-005) với target_type='family', target_id=family_id — không có bảng note mới. Thang visibility sẵn có đã đúng nhu cầu: mặc định internal_staff, nâng lên mentor_team hoặc cho gia đình thấy khi cần (Q-145).

Vì sao không làm "một ô nội bộ, một ô nhắn cho gia đình" như ghi chú learner đã có: thêm một khái niệm nữa là thêm một chỗ nữa để quên mất mình đang viết cho ai. Một ô, một cái chọn mức hiển thị ngay cạnh nút Lưu, giống hệt màn ghi chú learner mentor đang dùng hằng ngày.

6. Ảnh (REQ-MEN-14) ​

text
family_photos: id · family_id → families(id) · media_id → media(id)
  · caption? · description?
  · taken_at?      -- thời gian chụp, do người nhập khai
  · location?      -- MỘT Ô CHỮ TỰ DO (Q-147)
  · alt_text?
  · uploaded_by → users(id)
  · status(active|archived) · display_order · created_at

Bốn trường chỉ đạo nêu đích danh (caption · description · thời gian chụp · vị trí) đều là cột riêng, không nhét chung vào một ô ghi chú: có cột thì sắp theo thời gian được, lọc theo buổi được, và ba năm nữa vẫn biết tấm ảnh chụp ở đâu.

  • taken_at do người nhập khai, không đọc từ EXIF. Ảnh chuyển qua Zalo thường đã bị xoá sạch EXIF, nên đọc EXIF cho ra "không có ngày" ở phần lớn ảnh thật — một tính năng đúng trên giấy và rỗng trên thực tế. Mặc định điền sẵn ngày hôm nay, sửa được.
  • location là chữ, không phải toạ độ (Q-147). Toạ độ gắn với một đứa trẻ là dữ liệu nhạy cảm hơn hẳn một dòng chữ, và SDD-009 §5 vốn yêu cầu xoá EXIF chứ không phải khai thác nó.
  • media.visibility='internal', cứng (Q-146). Không có tham số nào trong luồng này đặt được public. Muốn một tấm ảnh lên nemo12.com thì phải đưa nó vào album công khai của SDD-019 — một hành động riêng, ở một màn hình riêng, nơi có chỗ ghi ai đã đồng ý.
  • alt_text cho phép NULL ở đây, khác photo_album_items (NOT NULL). Lý do khác nhau chứ không phải quên: album là trang công khai, người dùng trình đọc màn hình gặp nó mà không có alt thì thấy một trang trống; còn đây là kho ảnh nội bộ, và bắt gõ alt cho từng tấm trong một lượt tải 40 ảnh thì kết quả thực tế là mentor gõ "a" cho đủ thủ tục. Khi hiển thị, alt rơi về caption.

7. Phục vụ ảnh nội bộ ​

GET /v1/media/{id} hiện chỉ trả ảnh visibility='public' (SDD-019 §2). Đợt này mở đúng một nhánh nữa: visibility='internal' thì đòi phiên đăng nhập + vai nội bộ, và trả Cache-Control: private, no-store thay vì public, immutable.

Một immutable trên ảnh nội bộ nghĩa là CDN và trình duyệt trung gian được phép giữ bản sao — đúng thứ không được xảy ra với ảnh trẻ em. Đây là lý do cổng kiểm visibility được dựng sẵn từ SDD-019 dù lúc đó mọi ảnh đều public.

8. Quyền và nhật ký (Q-141) ​

Mọi route dưới /v1/mentor/families đi qua cùng cổng vai requireMentor (mentor · staff · admin) đã dùng cho phần còn lại của Dolphin, và nằm sau Cloudflare Access ở biên.

Quyền xem rộng bắt buộc đi kèm audit. Mỗi lượt mở một nhà, và mỗi lượt sửa, ghi một dòng audit_log qua logAccess(): ai · làm gì · lên nhà nào · lúc nào · từ đâu. Đây không phải trang trí: khi mọi mentor xem được mọi nhà, thứ duy nhất phân biệt "tra cứu để làm việc" với "tò mò về hàng xóm" là cái log đó.

audit_log.detail_json không chứa nội dung — chỉ id và loại hành động, theo AS-07.4.3 (sanitiseDetail đã chặn sẵn các khoá note, email, name…). Log sống lâu hơn dữ liệu gốc, nên mỗi lần đọc log lại là một lần lộ thêm nếu để nguyên văn.

9. API ​

MethodPathQuyền
GET · POST/v1/mentor/families🔐 role mentor
GET · PATCH/v1/mentor/families/{id}🔐 role mentor
POST/v1/mentor/families/{id}/contacts🔐 role mentor
PATCH/v1/mentor/contacts/{id}🔐 role mentor
POST/v1/mentor/contacts/{id}/link · /unlink🔐 role mentor
POST/v1/mentor/families/{id}/children🔐 role mentor
PATCH/v1/mentor/family-learners/{id}🔐 role mentor
GET · POST/v1/mentor/families/{id}/notes🔐 role mentor
GET/v1/mentor/families/{id}/scorecards🔐 role mentor
GET · POST/v1/mentor/families/{id}/photos🔐 role mentor
PATCH/v1/mentor/photos/{id}🔐 role mentor

Không có DELETE nào trong bảng này — lưu trữ là PATCH status='archived' (Q-150).

10. Cái chưa làm ​

ViệcVì sao để lại
Gộp hai gia đình trùngChuyển learners, guardians, evidence, mục tiêu thi sang khoá mới — đợt riêng (Q-151)
Gia đình tự xem ảnh của nhà mình trong marlinsChủ dự án chọn mức "nội bộ" cho đợt này (Q-146); mở sau thì chỉ thêm một mức visibility
Gợi ý nối theo số điện thoạiusers chưa có cột điện thoại; khi có thì thêm một nguồn gợi ý vào bảng §4
Nối ngược từ phía phụ huynhQ-143 chốt mentor duyệt; luồng mã mời đã có sẵn ở invitations nếu sau này cần

11. Danh sách chính xếp theo gia đình (REQ-MEN-15, SRC-585) ​

Cổng Dolphin trước đây mở ra bằng một danh sách learner phẳng kèm tiêu đề "Tất cả learners" và một dòng nhắc lại luật quyền xem. Cả ba thứ đều bị bỏ:

  • Tiêu đề trang: tab đang sáng ở thanh trên đã nói đây là màn gì. Một dòng chữ to lặp lại tên tab là một dòng không ai đọc lần thứ hai.
  • Dòng giải thích luật SRC-037: đúng chỗ của nó là tài liệu này, không phải màn hình mở ra mỗi ngày.
  • Nhãn "Quan sát →" trên từng thẻ: cả thẻ đã bấm được rồi (DS-001 §5b điều 7 và 9).

Danh sách phẳng cũng bị gộp vào danh sách gia đình, không chạy song song: hai danh sách cùng liệt kê gần như cùng một tập người là hai chỗ để lệch nhau. Learner vẫn mở được từ thẻ gia đình và vẫn giữ URL riêng /learner/{id}.

Hình dạng một thẻ — theo đúng thứ tự người ta hỏi về một nhà:

text
Nguyễn Văn Nam · Trần Thị Mai            [Mine] [Unclaimed]   ← dòng trên: bố mẹ
Doo doo (grade 8) · Cún (grade 2)                             ← dòng dưới: các con
Looked after by Hồng Hà        [I look after this family]

family_mentors — mentor tự nhận, khác mentor_assignments ​

mentor_assignments (0008)family_mentors (0079)
Tầnglearnergia đình
Ai tạoadmin giaomentor tự nhận
Nghĩaphân công chính thức"tôi đang chăm nhà này"

Hai trục khác nhau nên là hai bảng. Gộp lại thì mất khả năng phân biệt "được giao" với "tự nhận" — mà đó đúng là thứ một người quản lý cần đọc được. Tầng gia đình còn làm được việc tầng learner không làm được: nhận chăm một nhà chưa có đứa con nào trong hệ (mentor vừa dựng hồ sơ, con chưa vào).

Bỏ chăm là chuyển status='ended' chứ không xoá dòng: "ai đã từng chăm nhà này" là câu hỏi có thật lúc bàn giao.

Hai ô lọc ​

Mặc định bật cả hai: "nhà tôi chăm" và "nhà chưa ai chăm". Cộng dồn kiểu HOẶC, nên màn đầu ca trực trả lời đúng hai câu — việc của tôi và việc chưa ai nhận. Bỏ chọn cả hai thì hiện TẤT CẢ, không hiện rỗng: một danh sách trống trơn ngay sau khi người dùng bỏ hết ô lọc trông y hệt lỗi tải dữ liệu.

12. Giao diện tiếng Anh (REQ-MEN-16, SRC-586) ​

Toàn bộ dolphin.nemo12.com bằng tiếng Anh, cùng lối đã áp cho admin.nemo12.com. Ba chỗ dễ bỏ sót, và cả ba đều đã làm:

  1. Đường dẫn. Thanh địa chỉ là thứ người dùng nhìn thấy, nên /gia-dinh → /families, /doi-ngu → /team… Đường cũ không chết: normalisePath() đổi lặng bằng replaceState, đúng cách hash cũ đã được xử lý ở SRC-451.
  2. Thông báo lỗi từ API. Các endpoint /v1/showcase/* và /v1/mentor/* chỉ dolphin gọi, nên dịch thẳng ở nguồn. Không đụng tới thông báo của shared/middleware hay các module learner/parent dùng chung — chúng vẫn phải là tiếng Việt cho learn và marlins.
  3. Danh sách lý do chưa publish (publishBlockers, studentPublishBlockers) là chuỗi sinh ở backend rồi hiện nguyên văn trên màn hình, nên cũng phải dịch cùng.

Ngoại lệ có chủ đích: tên riêng tiếng Việt trong ví dụ gợi ý — placeholder="THPT chuyên Hà Nội - Amsterdam", placeholder="Hà Nội" — giữ nguyên. Đó là dữ liệu thật của một registry trường Việt Nam, không phải chữ giao diện; dịch chúng là làm hỏng ví dụ.

Thẻ mentor chỉ còn một link. Bốn nút (Sửa · Bỏ xác minh · Gỡ khỏi trang công khai · Xoá) nhân với hai chục người là hơn tám chục nút trên một màn, phần lớn là thao tác hiếm, và nút Xoá đứng ngay cạnh nút Sửa ở đúng chỗ mắt lướt nhanh nhất. Nay danh sách trả lời một câu "đội ngũ gồm những ai", còn /team/{id} trả lời "người này thế nào, và tôi cần làm gì với hồ sơ này".

Trace ​

REQ-MEN-11→§2 · REQ-MEN-12→§3-4 · REQ-MEN-13→§5, §15 · REQ-MEN-14→§6-7, §15.1 · REQ-MEN-15→§11 · REQ-MEN-16→§12. US-97→§2-3 · US-98→§4 · US-99→§5 · US-100→§6 · US-101→§11 · US-102→§12. Quyết định: Q-141..Q-150 (chủ dự án) · Q-151 (✍️ không gộp gia đình).

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.