SDD-019 — Hồ sơ Mentor & Album ảnh
Mảng này trả lời một câu hỏi của người chưa là người dùng: "Ai sẽ dạy con tôi, và tôi tin được điều đó dựa vào cái gì?" — nên khác mọi mảng trước ở ba chỗ:
- Đầu ra là trang công khai, không phải màn hình sau đăng nhập. Sai ở đây là sai trước mặt người chưa tin mình.
- Nội dung do người nội bộ soạn, không do learner sinh ra. Vì thế không có moderation queue ở đợt này (§2) — nhưng cũng vì thế mà lời khẳng định phải có dữ liệu đỡ (§4).
- Một nội dung, hai người đọc. Bố mẹ và con nhìn cùng tấm ảnh nhưng cần nghe hai câu khác nhau (§6).
Phạm vi đúng chỉ đạo SRC-201: quản lý hồ sơ mentor/advisor và album trong dolphin.nemo12.com, hiển thị ở nemo12.com. Không làm: xếp hạng mentor, đánh giá của phụ huynh, ghép mentor ↔ learner tự động (đã có mentor_assignments từ SDD-001 §6).
1. Nguyên tắc
- Lời khẳng định mạnh phải có cột dữ liệu đỡ nó. "Mọi mentor đều là cựu học sinh trường chuyên" là câu dễ mất niềm tin nhất trên toàn trang, vì nó kiểm chứng được. Nên nó không phải một dòng bio mà là bốn cột + một cổng publish (§4).
- Con số công khai đếm từ dữ liệu. Không có số nào viết cứng trong JSX. Sửa một hồ sơ thì trang chủ đổi theo, không cần ai nhớ đi sửa chỗ thứ hai.
- Bản nháp không rò ra công khai. API công khai lọc
status='published'ở tầng SQL, không lọc ở client. - Thiếu bản riêng thì rơi về bản gốc, theo từng trường một (§6) — mặc định hai vùng giống hệt nhau.
- Ảnh bất biến. Một
media.idtrỏ mãi mãi tới đúng một chuỗi byte; thay ảnh = tạo id mới. Nhờ vậyCache-Control: immutablelà thật chứ không phải lời hứa.
2. Ảnh — lát cắt đầu tiên của Media System (REQ-MED-07)
SDD-009 đặc tả hệ media đầy đủ nhưng chưa có dòng code nào. Đợt này thi hành đúng phần cần cho ảnh công khai, giữ nguyên tên cột để phần còn lại về sau là mở rộng chứ không phải viết lại:
media: id · kind(image) · r2_key · mime_type · byte_size · width? · height?
· checksum_sha256 · visibility(public|internal) · moderation_status
· alt_default? · uploaded_by → users(id) · created_at- Bytes nằm ở R2
nemo12-content, keymedia/<yyyy>/<id>.<ext>— đúng SDD-009 §1. - Tải lên:
POST /v1/showcase/mediabody nhị phân thuần (không multipart), mime lấy từContent-Type. Chọn cách này vì Worker không phải parse multipart, và ảnh chân dung/album vài trăm KB thì đường đi qua Worker rẻ hơn hẳn việc dựng presigned URL cho một cổng chỉ vài người dùng. Khi có upload của learner (khối lượng khác hẳn) thì mới cần direct-to-storage như SDD-009 §3. - Chặn ở cổng vào: mime ∈ {jpeg, png, webp}, ≤ 5 MB, và tên tệp do client gửi bị bỏ đi — đuôi tệp suy ra từ mime. Tên tệp là chuỗi do người ngoài đặt; đưa nó vào key R2 là mở đường cho ký tự lạ và cho việc đoán ra đường dẫn.
checksum_sha256tính lúc nhận. Cùng một tấm ảnh tải lên hai lần vẫn ra haimedia.id— không khử trùng lặp, vì gộp hai row lại nghĩa là xoá một cái sẽ làm hỏng chỗ còn lại.- Cố ý chưa làm (Q-132): variants Cloudflare Images, signed URL, moderation queue, versioning, EXIF strip. Ảnh ở đây do người nội bộ sau Cloudflare Access đưa lên nên
moderation_statusmặc địnhapproved; ngày có ảnh learner thì mặc định phải làpendingvà cổng serve phải kiểm — chỗ kiểm đó đã có sẵn trongGET /v1/media/{id}.
Serve: GET /v1/media/{id} công khai, chỉ trả khi visibility='public' và moderation_status='approved', kèm Cache-Control: public, max-age=31536000, immutable và ETag = checksum.
3. Hồ sơ Mentor & Advisor (REQ-MEN-07)
mentor_profiles: id · user_id? UNIQUE → users(id) · kind(mentor|advisor)
· full_name · short_name? · headline? · photo_media_id? → media(id)
· current_org? · subjects_json · years_experience?
· bio? · bio_parent? · bio_student?
· alumni_* (§4)
· status(draft|published|archived) · display_order · created_at · updated_atuser_idcho phép NULL. Cố vấn (advisor) thường là người đồng hành về chuyên môn, không ngồi trong cổng làm việc. Bắt phải có tài khoản trước khi được nhắc tên là bắt tạo tài khoản ma — và tài khoản ma thì hoặc lơ lửng mãi, hoặc một ngày có người đăng nhập được vào nó.kindtách mentor với advisor để trang công khai xếp hai nhóm riêng; quyền hạn trong hệ vẫn dorole_assignmentsquyết, hồ sơ này không cấp quyền gì. Đây là chỗ dễ nhầm nhất:mentor_profileslà trang giới thiệu,role_assignmentslà quyền. Hai thứ độc lập, và cố tình không nối vào nhau — người rời đội thì gỡ quyền ngay, còn ảnh trong album cũ vẫn là chuyện khác.bioba bản theo đúng luật §6.
4. Bằng chứng cựu học sinh trường chuyên (REQ-MEN-08)
Đây là phần chủ dự án yêu cầu "làm nổi bật", và cách làm nổi bật đúng là làm nó kiểm chứng được, không phải in to lên:
alumni_school "THPT chuyên Hà Nội - Amsterdam" -- tên đầy đủ
alumni_school_short "Ams" -- hiện trên thẻ
alumni_track "Chuyên Toán"
alumni_grad_year 2016
alumni_verified 0|1
alumni_verified_at · alumni_verified_by → users(id)
alumni_note -- NỘI BỘ: đã xem gì để tin (bằng, ảnh lớp, người giới thiệu)Cổng publish (thi hành trong service, không chỉ trên UI):
status='published'chỉ đặt được khikind='mentor'có đủalumni_school+alumni_track+alumni_grad_yearvàalumni_verified=1.
Advisor không bị cổng này chặn — cố vấn có thể là giáo viên, chuyên gia ngoài ngành, và ép họ vào khuôn "cựu học sinh chuyên" là đẩy dữ liệu sai vào để qua cửa. Vì thế con số công khai nói đúng phạm vi của nó: đếm trên mentor, không đếm chung với advisor.
alumni_note không bao giờ đi ra API công khai — nó chứa lời kể về người thứ ba ("cô X xác nhận"), thuộc loại thông tin nội bộ theo SDD-006 §privacy.
Trang công khai trả kèm một khối đếm được:
{ "mentors": 12, "alumni_verified": 12, "schools": ["Ams", "CVA", "KHTN"] }Nếu một ngày alumni_verified < mentors thì câu chữ trên trang tự đổi từ "mọi mentor đều là cựu học sinh trường chuyên" sang dạng đếm ("12/14 mentor"). Không có đường nào để trang nói một đằng dữ liệu một nẻo.
5. Album (REQ-MEN-09)
photo_albums: id · slug UNIQUE · title(+_parent,+_student)
· caption(+_parent,+_student) · description(+_parent,+_student)
· cover_media_id? → media(id) · audience(both|parent|student)
· status(draft|published|archived) · featured · display_order · taken_on? · timestamps
photo_album_items: id · album_id → photo_albums(id) · media_id → media(id)
· alt_text (BẮT BUỘC) · caption(+_parent,+_student) · display_order · created_at
photo_album_people: (album_id, mentor_profile_id) PK · created_ataudiencekhác với caption hai bản.audiencetrả lời "album này có được xuất hiện ở vùng đó không" (buổi họp phụ huynh thì học sinh không cần thấy); caption hai bản trả lời "nói thế nào khi nó đã xuất hiện". Gộp hai khái niệm vào một cột thì mất khả năng có album chỉ dành cho một vùng.alt_textbắt buộc ở tầng schema (NOT NULL), không phải nhắc nhở trên UI. Album là bằng chứng niềm tin; 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.- Gắn người ở tầng album, không ở tầng từng ảnh. Gắn theo ảnh chính xác hơn nhưng bắt người soạn khoanh mặt trong từng tấm — công việc đó không bao giờ được làm đến nơi, và nửa vời thì tệ hơn không có (Q-133).
slugdùng thẳng trên URL công khai/for-parents#<slug>, nên đổi slug là gãy link cũ; cổng quản lý cảnh báo khi sửa slug của album đã publish.
6. Một nội dung, hai người đọc (REQ-BRD-08) — luật lõi
Mỗi trường chữ có ba cột: bản gốc + bản cho bố mẹ + bản cho học sinh. Phân giải theo từng trường một:
pick(base, parent, student, audience) =
audience === "parent" ? (parent ?? base) :
audience === "student" ? (student ?? base) :
baseBa tính chất đi thẳng từ chỉ đạo SRC-201:
| Người soạn điền | Bố mẹ thấy | Học sinh thấy |
|---|---|---|
| chỉ bản gốc | bản gốc | bản gốc (giống hệt nhau) |
| gốc + cả hai bản riêng | bản bố mẹ | bản học sinh |
| gốc + một bản riêng | bản riêng nếu có, không thì gốc | như trên |
Hàng thứ ba là chỗ dễ cãi nhau. Cách khác — "phải điền đủ đôi mới tách" — nghe gọn hơn nhưng có một hệ quả xấu: người soạn điền xong bản cho bố mẹ, lưu lại, và không thấy gì thay đổi cả, vì bản kia còn trống. Im lặng nuốt mất công vừa làm là cách nhanh nhất khiến người ta nghĩ tính năng hỏng. Nên hệ chọn fallback theo từng trường, và cổng quản lý nói thẳng trạng thái nửa vời: "Bố mẹ: bản riêng · Học sinh: đang dùng bản gốc" (Q-131).
Chuỗi rỗng '' được coi như chưa điền (NULLIF ở tầng SQL) — ô nhập bị xoá sạch trong trình duyệt gửi lên '', và nếu tính đó là "bản riêng rỗng" thì màn hình công khai mất chữ.
7. Vùng hiển thị trên nemo12.com (REQ-BRD-08)
Chủ dự án chọn (2026-08-17) phương án hai trang riêng + dải ngắn ở trang chủ:
| Bề mặt | Nội dung | audience |
|---|---|---|
/ (trang chủ) | dải đội ngũ: khối đếm §4 + tối đa 6 mentor + link sang hai trang | bản gốc |
/for-parents | mọi mentor + album audience ∈ {both, parent} | parent |
/for-students | mọi mentor + album audience ∈ {both, student} | student |
/mentors/{slug} | trang riêng của một mentor | bản gốc |
Trang chủ dùng bản gốc vì lúc đó hệ thống chưa biết ai đang xem — đoán rồi nói sai giọng còn tệ hơn nói giọng trung tính. Đây cũng là lý do hai trang riêng tồn tại: người đọc tự khai mình là ai bằng cú bấm, và từ đó trở đi mọi câu chữ đều đúng người (cùng logic hai cửa vào của WF-19 §2).
7b. Thẻ mentor rút còn ba thứ, chi tiết dời sang trang riêng (SRC-520)
Chỉ đạo 2026-08-23: "chỉ để lại ảnh, tên, và cựu học sinh chuyên tin..." và "cần tạo các trang riêng cho từng mentor ở slug là mentors/mentor-slug".
Thẻ trong danh sách chỉ còn ảnh · tên · một dòng "cựu học sinh chuyên…". Bản trước có thêm một dòng trường·lớp·năm màu cam và một cụm badge môn: bốn tầng chữ trên một thẻ, trong đó dòng cam lặp gần nguyên văn phần đầu của headline ngay dưới nó. Người lướt danh sách chỉ cần trả lời một câu — người này là ai.
Trang riêng là nơi được phép dài, vì người đọc đã chủ động bấm vào một cái tên. Trường·lớp chuyên·năm tốt nghiệp quay lại ở đây dưới dạng cặp nhãn/giá trị chứ không phải một dòng chạy — người đang kiểm chứng cần đọc được từng mục, và đây chính là chỗ đỡ cho câu "tất cả đều là cựu học sinh trường chuyên" ở §4.
slug là cột riêng, không sinh tại chỗ từ full_name: tên có dấu và có người trùng tên, còn đường dẫn đã gửi đi thì phải sống lâu hơn một lần sửa tên hiển thị. UNIQUE nhưng cho phép NULL để hồ sơ nháp chưa đặt slug vẫn lưu được (migration 0070).
Trang chủ giữ một dải, không hai — DS-001 §5b luật 2 (cực ít element/màn); hai dải album cạnh nhau đẩy lưới school xuống dưới màn hình thứ ba.
8. API
| Method | Path | Quyền |
|---|---|---|
| GET | /v1/public/mentors?audience= | công khai |
| GET | /v1/public/mentors/{slug}?audience= | công khai |
| GET | /v1/public/albums?audience= | công khai |
| GET | /v1/public/albums/{slug}?audience= | công khai |
| GET | /v1/public/photos?audience=&limit= | công khai, cùng bộ lọc album (§17) |
| GET | /v1/media/{id} | công khai (chỉ media public + approved) |
| GET · POST | /v1/showcase/mentors | 🔐 role mentor/staff/admin |
| PATCH · DELETE | /v1/showcase/mentors/{id} | 🔐 như trên |
| GET · POST | /v1/showcase/albums | 🔐 như trên |
| PATCH · DELETE | /v1/showcase/albums/{id} | 🔐 như trên |
| POST | /v1/showcase/albums/{id}/items | 🔐 như trên |
| PATCH · DELETE | /v1/showcase/items/{id} | 🔐 như trên |
| PUT | /v1/showcase/albums/{id}/people | 🔐 như trên |
| POST | /v1/showcase/media | 🔐 như trên |
Nhánh /v1/public/* không gắn requireSession, giống /v1/whale/opportunities — phụ huynh phải xem được đội ngũ trước khi có tài khoản. Đổi lại, mọi truy vấn trong nhánh này chỉ chạm bốn bảng của SDD này và luôn kèm status='published'; không có learner_id nào đi qua đây.
DELETE là xoá thật dòng album/item (không phải soft delete) vì đây là nội dung marketing do nội bộ soạn, không phải dữ liệu của trẻ — nhưng media thì giữ lại: bytes trên R2 có thể đang được album khác dùng, và dọn media mồ côi là việc của lifecycle SDD-009 §6.
9. Cái chưa làm
| Việc | Vì sao để lại |
|---|---|
| Variants ảnh (thumb/card/full) | Ảnh gốc ≤5 MB qua CDN đủ cho vài chục ảnh; bật Cloudflare Images là quyết định chi phí (Q-132) |
| Moderation queue | Không có ảnh do learner/phụ huynh tải lên trong phạm vi này (§2) |
| Đánh giá/xếp hạng mentor | Không có trong SRC-201, và xếp hạng người trong đội là quyết định sản phẩm nặng hơn nhiều |
| Gắn mentor theo từng ảnh | Q-133 — album-level trước, nâng lên được mà không phải đổi bảng nào |
| Song ngữ (name_en…) | Trang công khai hiện chỉ có tiếng Việt; khi bật EN thì thêm cột như SDD-013 đã làm |
Trace
REQ-MED-07→§2 · REQ-MEN-07→§3 · REQ-MEN-08→§4 · REQ-MEN-09→§5 · REQ-BRD-08→§6-7. US-84→§3 · US-85→§4 · US-86→§5 · US-87→§6-7. Quyết định ✍️: Q-131 (fallback từng trường) · Q-132 (phạm vi media) · Q-133 (gắn người ở tầng album) · Q-134 (advisor không cần tài khoản).
10. Cảm nhận: một nguồn duy nhất cho tấm thẻ công khai (SRC-648)
Bảng testimonials mang đủ những gì tấm thẻ trên /students và /parents cần: said_on (ngày nói, chuỗi người đọc được vì phần lớn cảm nhận chỉ nhớ tới tháng), facts_json (bốn ô nhãn tự do, trần bốn là ràng buộc bố cục và kiểm ở API), và is_sample.
Trang công khai đọc API trước, chỉ rơi về mảng mẫu trong media.ts khi chưa có cảm nhận nào đã publish. Rơi về mẫu chứ không để trống: một khối rỗng trông như trang hỏng, còn nội dung mẫu thì tự nói ra rằng nó là mẫu. Câu cảnh báo "đây là nội dung mẫu" tính từ dữ liệu đang hiện, nên nó tự tắt khi lời thật thay chỗ.
Trước đợt này hai bên rời nhau: trang dựng từ mảng cứng, dolphin quản bảng D1. Một màn quản lý không nối được ra trang là một màn trang trí.
11. Rà sai vai người đọc, và cổng giữ danh sách cột trắng (SRC-903)
Sau khi SRC-884 để lọt một lỗi sai vai người đọc ở mạch IELTS và SRC-897 dựng cổng cho nó, chủ dự án bảo rà nốt hai vùng còn lại: showcase và thư gửi phụ huynh.
a. Kết quả rà: không có rò nào đang tồn tại
Nói trước cho khỏi hiểu nhầm — đây không phải một bản vá sự cố. Cả hai vùng hôm nay đều sạch, và sạch vì những lý do khác nhau:
- Thư phụ huynh sạch vì một lý do khoẻ, không phải vì may: thư tổng kết chỉ đọc số từ
learner_evidence, thư IELTS chỉ đọc trường có cấu trúc (mục tiêu, band, đếm ngược). Không có chữ tự do nào từ cột trộn vai đi vào thư. Không có gì để gác ở đây, và nói ra điều đó có ích hơn là thêm một luật giả vờ. - Showcase sạch vì hai lớp chồng nhau: danh sách cột trắng ngay trong câu truy vấn (
TESTIMONIAL_PUBLIC_COLS,MENTOR_PUBLIC_COLS), và hàm chiếu (pick,toPublicTestimonial). Đường quản lý đi quarequireShowcaseRolenêntoAdminAlbum— vốn trải nguyên hàng — không với tới người ngoài.
b. Nhưng cả hai lớp ấy đều bất thành văn
Đúng tình trạng của ai_notes_json trước SRC-884. Nên §11 kéo showcase vào cổng scripts/check-audience.mjs với ba luật mới:
- Cột nội bộ không được nhắc tên trong file phục vụ người ngoài:
testimonials.internal_note·testimonials.consent_note·school_students.consent_note·school_students.verification_note. Cột cuối là ghi chú đối chiếu về một đứa trẻ có tên thật. SELECT *từ một bảng có cột nội bộ bị chặn trên đường công khai. Luật này gắn vào tên bảng chứ không tên cột, vì dấu sao xoá sạch tên cột khỏi mã nguồn.- Cột biến thể (
*_parent/*_student) phải đi quapick(): chỉ một bản được rời khỏi worker. Trả cả hai là để vùng này đọc lời viết cho vùng kia — đúng định nghĩa lỗi sai vai, chỉ khác là nó nằm trong một cặp cột thay vì một cục JSON.
c. Hai chỗ luật phải sửa lại sau khi đo thật
Bản đầu của cả hai luật đều sai, và chỉ lộ ra khi cố tình phá code để thử:
- Luật cột nội bộ ban đầu đòi tên cột đứng gần chữ
SELECT. Nhưng danh sách cột của showcase nằm trong một hằng số riêng rồi mới nội suy vào truy vấn, nên thêminternal_notevào chính hằng số ấy thì cổng vẫn xanh — tức là nó mù với đúng cơ chế đang bảo vệ trang công khai. Nay luật bắt tên cột ở bất kỳ đâu trong mã (chú thích đã được gỡ trước khi quét). - Luật cột biến thể ban đầu nhận bằng hậu tố
_parent/_studenttrần, và bắt nhầm ba module vô can:learner_parentlà một giá trịvisibilitycủa bảnginteractions. Nay nhận bằng cặp —title_parentchỉ tính khititle_studentcũng có mặt — nên cặp thứ năm được phủ sẵn mà không ai phải nhớ thêm tên.
d. Còn mở
Cổng đọc bằng biểu thức chính quy, không phải cây cú pháp. Nó không thấy một câu SQL ghép từ nhiều biến ở mức phức tạp hơn một hằng chuỗi, và không thấy SELECT * rồi trải nguyên hàng khi tên bảng chưa được khai trong INTERNAL. Danh sách bảng ấy là thứ phải nuôi.
12. Màn Albums chỉ đọc mặc định (SRC-1178)
Chỉ đạo 01.10.2026: bỏ mọi icon và tính năng Delete; vào màn là chỉ đọc; bấm Edit mới sửa, Cancel để huỷ.
Ba tầng, tầng nào cũng chỉ đọc khi mở ra:
| Tầng | Chỉ đọc | Thao tác |
|---|---|---|
| Danh sách album | ảnh bìa, tên, trạng thái bằng chữ, số ảnh, đối tượng | "New album" mới hiện ô tạo |
| Một album | mọi trường, người xuất hiện, lưới ảnh | "Edit" (Save/Cancel), "Upload photos" |
| Một tấm ảnh | ảnh lớn, alt text, caption | "Edit" (Save/Cancel) |
Lý do làm chặt: mọi ô ở đây là chữ đang hiện trên nemo12.com. Màn mở ra là thấy ô nhập thì một cú gõ nhầm khi chỉ định XEM cũng thành thay đổi trên trang công khai, và nút Delete đứng cạnh nút Publish là một cú bấm trượt không hoàn tác được.
Ba quyết định:
- Không còn xoá ở giao diện — cả album lẫn ảnh. Endpoint API vẫn giữ, chỉ giao diện bỏ.
- Người xuất hiện trong album lưu cùng nhịp Save. Trước đây mỗi cú bấm chip lưu ngay; giữ vậy thì Cancel không huỷ được phần này và "Cancel để huỷ việc sửa" chỉ đúng một nửa.
- Đăng / gỡ đăng nằm trong chế độ sửa: đó là thay đổi lớn nhất với một album, không đứng ở màn chỉ xem.
13. Tạo album và tải ảnh là trang riêng (SRC-1180)
Hai thao tác tạo ra nội dung mới mở bằng một nút icon và dẫn tới trang riêng có URL:
| Đường dẫn | Trang |
|---|---|
/albums | danh sách (chỉ đọc), nút icon "+" tạo album |
/albums/new | tạo album; tạo xong vào thẳng album đó |
/albums/:id | một album (chỉ đọc), nút icon "↑" tải ảnh, nút Edit |
/albums/:id/upload | tải ảnh vào album đó |
/albums/:id/photos/:photoId | một tấm ảnh (chỉ đọc), nút Edit |
URL thật chứ không phải state: gửi link là mở đúng chỗ, nút Back của trình duyệt đi đúng một bước — cùng lý do /families/:id có URL riêng (SRC-584).
Nút tải ảnh chỉ có khi đang xem một album, và cũng ẩn khi album đang ở chế độ sửa: ảnh luôn thuộc về một album, nên ở danh sách nó không có chỗ để đi. /albums/upload vì vậy là một album tên "upload", không phải trang tải ảnh mồ côi — test chốt điều đó.
Nút icon dùng ký tự Unicode thường, không phải emoji, nhãn đầy đủ nằm ở aria-label và title: một icon không tên thì người mới không biết bấm vào sẽ ra gì.
Trang tải ảnh báo kết quả từng tệp: chọn mười ảnh mà một ảnh hỏng thì phải biết đúng ảnh nào, không phải một dòng lỗi chung đè lên chín ảnh đã vào được.
14. Mô tả chung, alt text tự sinh, Save/Cancel tại chỗ (SRC-1181)
Mô tả chung. photo_album_items.description (migration 0323, lên 03.10.2026) là MỘT câu chuyện về tấm ảnh, dùng chung cho phụ huynh lẫn học sinh. Caption giữ ba phiên bản như cũ; mô tả thì một cột là đủ — tách ba bản là bắt người soạn viết lại cùng một sự việc ba lần.
Alt text tự sinh. Ô nhập alt text đã bỏ khỏi giao diện, nhưng alt text vẫn bắt buộc ở lược đồ (§5) vì người dùng trình đọc màn hình và máy tìm kiếm cần nó. workers/api/src/modules/showcase/altText.ts dựng từ chữ người soạn đã viết, theo thứ tự: caption → câu đầu của mô tả → tên album → tên tệp đã làm sạch. Tên album được ghép vào khi còn chỗ để cùng một caption ở mười album vẫn phân biệt được. Dựng khi thêm ảnh và mỗi lần caption đổi; không dựng lại thì alt text đứng mãi ở tên tệp lúc tải lên.
Tên tệp là phương án cuối và được làm sạch: Screenshot 2026-07-17 at 23.25.22 — đúng thứ đã lọt lên giao diện — là alt text tệ hơn không có, vì máy đọc nó lên như thật. Alt text vẫn HIỆN ở chế độ xem (chữ nhỏ) để người soạn biết máy đang đọc gì.
Save/Cancel tại chỗ. Bấm Edit thì Save và Cancel thay đúng chỗ nút Edit, ở cả trang ảnh lẫn trang album. Để ở đáy form thì trên màn nhỏ phải cuộn mới thấy đường ra.
Hai cột. Trang ảnh: ảnh một bên (sticky), thông tin một bên. Người soạn viết caption trong lúc NHÌN ảnh; xếp chồng thì ảnh trôi khỏi màn ngay khi bắt đầu gõ. Màn hẹp về một cột, ảnh trên.
Hiện trên nemo12.com (SRC-1182). Endpoint công khai trả thêm description của từng ảnh, ghi tường minh trong danh sách trường như mọi trường khác (đầu ra công khai là danh sách cột trắng, §11). Nó không đi qua pick() vì chỉ có một bản chung. Ở ba trang có album (/for-parents, /for-students, /community), mỗi khung ảnh hiện caption rồi mô tả ngay dưới; không có chữ nào thì không vẽ khung trống.
Vì sao cột mô tả chưa lên. Migration của nó không đẩy được: main dừng ở 0320, còn 0321 và 0322 thuộc hai phiên khác đang làm dở. Dãy migration phải liền mạch (AS-04.1.1), nên lấy "số kế tiếp" nghĩa là lấy số của phiên khác — họ phải đánh số lại cả nhánh — còn lấy một số khác thì để lại một lỗ làm CI đỏ cho mọi phiên. Phần không cần cột (bố cục, alt text tự sinh, Save/Cancel tại chỗ) đã đẩy trước; ô Description và việc hiện nó trên nemo12.com (API công khai và Team.tsx đã đọc sẵn trường này, hiện chữ ngay khi có dữ liệu) chờ hai số kia gộp.
15. Album lớp chuyển từ sutucon.com (SRC-1191)
Chủ dự án 03.10.2026: mọi album ảnh mentor đã đưa lên sutucon.com (qua rafiki.sutucon.com, bảng class_album_photos) chuyển sang photo albums của dolphin, cùng mọi chữ đi kèm ảnh. Lúc chuyển chỉ có MỘT album: lớp AI Teen, 23 ảnh (19 công khai, 4 không công khai), từ 04.06.2026 tới 19.08.2026.
Ánh xạ. Mỗi lớp bên sutucon là một album (photo_albums.source_ref = sutucon:classes:<id>). Mỗi ảnh mang theo caption, mô tả (cột của 0323), lúc chụp, nơi chụp, tên tệp gốc và người tải lên (nối theo email vào users). Bên dolphin thiếu bốn cột, migration 0324 thêm: photo_album_items.taken_at, location, original_filename, source_ref, cộng photo_albums.source_ref. Id của ảnh giữ nguyên id gốc, nên chạy lại không sinh bản trùng.
Ảnh "không công khai" là media.visibility='internal', không phải một cột mới: cổng ở GET /v1/media/{id} đã chặn bytes của loại này (SDD-023 §7). Nhưng chặn bytes chưa đủ, vì caption của các ảnh ấy kể chuyện trẻ em có tên thật. Nên /v1/public/albums/{slug} bỏ hẳn ảnh internal khỏi danh sách và photo_count không đếm chúng; cổng quản lý vẫn thấy đủ.
Album vào ở trạng thái draft. Bên sutucon nó đã công khai, nhưng lên nemo12.com là một người đọc mới; mentor xem lại rồi tự bấm xuất bản. Ảnh đã ẩn (hidden_at) bên sutucon không chuyển.
Đường đi, đúng luật deploy-qua-GitHub. scripts/import-sutucon-albums.mjs đọc ảnh chụp dữ liệu nguồn scripts/data/sutucon-album-ai-teen.json, sinh manifest ảnh và file seed. Workflow import-media.yml tải từng ảnh, kiểm sha256 khớp với checksum seed sẽ ghi, rồi đặt vào R2 nemo12-content dưới media/<năm>/; sau đó seed-data.yml nạp scripts/seed-sutucon-albums.sql. Thứ tự này là bắt buộc: seed trước thì album trỏ vào ảnh chưa tồn tại.
Màn ảnh ở dolphin hiện thêm Mô tả, Lúc chụp (DD.MM.YYYY HH:MM) và Nơi chụp; hai ô sau sửa được khi bấm Edit. Test: workers/api/src/modules/showcase/albumImport.test.ts.
Album ở cuối trang chủ www (chỉ đạo 03.10.2026). HomeAlbums trong apps/web/src/Team.tsx vẽ danh sách album đã xuất bản ngay dưới màn chọn vai; bấm một album thì mở ảnh tại chỗ, kèm ngày chụp (DD.MM.YYYY) và nơi chụp. Trang chủ chưa biết người đọc là phụ huynh hay học sinh nên đọc vùng public. Chưa có album nào xuất bản thì khối này không vẽ gì. Album AI Teen được xuất bản bằng scripts/seed-sutucon-albums-publish.sql.
16. Album Mentors: một ảnh chân dung mỗi mentor (SRC-1234)
Chỉ đạo 04.10.2026: album mentors trên dolphin giữ một ảnh chân dung cho mỗi mentor, tạo sẵn khung trống để chủ dự án tải ảnh sau; đồng thời bỏ Phương Anh khỏi danh sách mentor.
Khung trống là một dòng media sentinel, không phải migration. photo_album_items.media_id là NOT NULL có khoá ngoại; nới thành NULL phải dựng lại bảng trên SQLite. Thay vào đó khung trống trỏ vào dòng media media-photo-pending (internal + hidden): /v1/media/{id} không trả bytes của nó, và mọi truy vấn album công khai vốn đòi media public nên tự bỏ qua khung trống. Album đã xuất bản mà chưa có ảnh công khai nào thì không vào /v1/public/albums, để www không hiện một thẻ album rỗng.
Điền khung trên dolphin. PATCH /v1/showcase/items/{id} nhận thêm media_id (phải là media đã có, không được là sentinel). Màn một ảnh ở dolphin (/albums/album-mentors/photos/<item>) vẽ ô "Chưa có ảnh" kèm nút "Tải ảnh lên"; ảnh đã có thì nút là "Thay ảnh". Caption, mô tả và alt text đứng nguyên.
Ghép ảnh với mentor. GET /v1/public/mentors và /v1/public/mentors/{slug}: mentor chưa có photo_media_id lấy ảnh của khung trong album mentors (đã xuất bản) có caption hoặc alt text chứa đúng họ tên đầy đủ, và chỉ khi khung ấy đã có ảnh thật. Tên phải đứng riêng (từ trước nó không viết hoa), nên "Hồng Hà" không khớp "Đỗ Hồng Hà". Ảnh riêng của hồ sơ luôn thắng. www vẽ ảnh qua MentorAvatar (apps/web/src/site/MentorsSection.tsx) ở khối Mentors của các trang môn, trang mentors IELTS/SAT và SpeakTrust; chưa có ảnh thì lùi về vòng chữ cái.
Dữ liệu. scripts/seed-mentors-album-src1234.sql, chỉ INSERT OR IGNORE nên nạp lại không đè ảnh đã tải. Phương Anh: status='archived' (không xoá dòng). Test: workers/api/src/modules/showcase/mentorPortraits.test.ts.
17. Ảnh album thay tranh minh hoạ trên www (SRC-1274)
Chủ dự án 07.10.2026: trang chủ nemo12.com bỏ tranh minh hoạ sinh máy, thay bằng ảnh THẬT từ các album tải lên qua dolphin; các trang www khác cũng dùng ảnh album ở chỗ trước đây là tranh minh hoạ.
Luật riêng tư (không thương lượng). www chỉ hiện ảnh mà API công khai đã cho ra. Endpoint mới GET /v1/public/photos?audience=&limit= là một danh sách phẳng, chỉ đọc, áp ĐÚNG bộ lọc của /v1/public/albums và /v1/public/albums/{slug}: album status='published', audience hợp với vùng đọc, media visibility='public'. Thêm hai khoá, không bớt khoá nào: media moderation_status='approved' (cổng /v1/media/{id} vốn từ chối mọi trạng thái khác) và loại thẳng theo id dòng media-photo-pending của §16. Đầu ra là danh sách cột trắng (§11): id, media_id, alt_text của ảnh, album_slug, album_title (đi qua pick()). Không cột nào khác rời worker. Test hành vi: workers/api/src/modules/showcase/publicPhotos.test.ts (nháp, lưu trữ, internal, pending, hidden, blocked, khung trống, sai vùng đọc, gỡ đăng có hiệu lực ngay).
Không nhúng lúc build. Khác dữ liệu công khai dựng sẵn của SRC-1170, danh sách ảnh KHÔNG đi vào HTML tĩnh: một ảnh đã nằm trong HTML thì vẫn còn trên trang sau khi album bị gỡ đăng, cho tới lượt deploy sau. Bản dựng sẵn chỉ có ô trung tính; trình duyệt tải danh sách mới mỗi lượt xem, nên gỡ đăng ở dolphin có hiệu lực ngay lượt xem kế tiếp. Phía client lọc thêm một lần khung trống (khoá thứ hai, không phải cổng).
Alt text lấy nguyên alt_text của từng ảnh trong album (§14), không viết lại.
Trang chủ. Lưới 12 ô vuông rút ngẫu nhiên từ toàn bộ ảnh công khai; mỗi 3 giây hai cặp ô RỜI NHAU đổi chỗ bằng hiệu ứng layout của motion. Không đổi chỗ khi người dùng bật giảm chuyển động, khi tab ẩn, hoặc khi lưới ra khỏi màn hình. Ô có tỉ lệ cố định nên không dịch bố cục. 375px: 3 cột x 4 hàng, vẫn đủ 12 ảnh. Mỗi ô dẫn tới /community#album-<slug>, nơi album mở ngay tại chỗ (www chưa có trang riêng cho từng album).
Trang khác: ánh xạ trang sang ảnh.
- Vùng đọc theo đường dẫn:
/parents/**,/for-parentsđọcaudience=parent;/students/**,/for-studentsđọcaudience=student; còn lạipublic. Album chỉ dành cho bố mẹ không lên trang học sinh. - Khoá chương trình là đoạn đầu của đường dẫn sau tiền tố vai (
/parents/ieltsraielts). Album chưa có cột chương trình nên slug là nhãn duy nhất: album có slug bằng khoá hoặc bắt đầu bằng<khoá>-được ưu tiên (/ai-teenlấy ảnh albumai-teen-class-2026). - Trong tập ứng viên (album gắn chương trình, không có thì toàn bộ), chọn theo băm FNV-1a của đường dẫn (thẻ trong trang: đường dẫn +
#+ khoá thẻ) trên thứ tự ổn định của API. Cùng kho ảnh thì cùng trang luôn ra cùng ảnh.
Khi thiếu ảnh. API lỗi: ô trung tính, không tranh minh hoạ. Có ít ảnh: phần còn lại của lưới là ô trung tính, không lặp ảnh. API trả lời và KHÔNG có ảnh công khai nào: lúc đó mới quay về tranh minh hoạ cũ, vì một website toàn ô xám đọc ra là làm dở (SDD-029 §6.1). Banner IELTS (SRC-1239) và ảnh thẻ sinh bằng thuật toán không phải tranh AI nên giữ nguyên. Bộ vẽ @nemo12/illustrations vẫn dùng ở learn và marlins; ở www nó chỉ còn là phương án cuối.
Code: apps/web/src/site/photos/ (photoLogic.ts hàm thuần, PhotoMosaic.tsx, PagePhoto.tsx). Kiểm chứng trên trình duyệt: npm run test:photos trong apps/web (giả API, đồng hồ giả page.clock).