---
url: https://docs.nemo12.com/architecture/sdd-019-mentor-albums.md
description: >-
  Hồ sơ Mentor và album ảnh (SDD-019): trả lời 'ai dạy con tôi, tin được vì
  sao', lát cắt ảnh đầu tiên của Media System.
---

# 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ỗ:

1. **Đầ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.
2. **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).
3. **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](sdd-001-platform.md) §6).

## 1. Nguyên tắc

1. **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).
2. **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.
3. **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.
4. **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.
5. **Ảnh bất biến.** Một `media.id` trỏ mãi mãi tới đúng một chuỗi byte; thay ảnh = tạo id mới. Nhờ vậy `Cache-Control: immutable` là 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](sdd-009-media.md) đặ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:

```text
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`, key `media/<yyyy>/<id>.<ext>` — đúng [SDD-009](sdd-009-media.md) §1.
* Tải lên: `POST /v1/showcase/media` **body 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_sha256` tính lúc nhận. Cùng một tấm ảnh tải lên hai lần vẫn ra hai `media.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_status` mặc định `approved`; ngày có ảnh learner thì mặc định phải là `pending` và cổng serve phải kiểm — chỗ kiểm đó đã có sẵn trong `GET /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)

```text
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_at
```

* **`user_id` cho 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ó.
* **`kind` tách mentor với advisor** để trang công khai xếp hai nhóm riêng; quyền hạn trong hệ vẫn do `role_assignments` quyết, hồ sơ này **không cấp quyền gì**. Đây là chỗ dễ nhầm nhất: `mentor_profiles` là *trang giới thiệu*, `role_assignments` là *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.
* `bio` ba 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:

```text
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 khi `kind='mentor'` có đủ `alumni_school` + `alumni_track` + `alumni_grad_year` **và** `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](sdd-006-reliability.md) §privacy.

Trang công khai trả kèm một khối đếm được:

```json
{ "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)

```text
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_at
```

* **`audience` khác với caption hai bản.** `audience` trả 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_text` bắ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).
* `slug` dù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**:

```ts
pick(base, parent, student, audience) =
  audience === "parent"  ? (parent  ?? base) :
  audience === "student" ? (student ?? base) :
                            base
```

Ba 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](../workflows/two-door-onboarding.md) §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](../design-system/index.md) §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](sdd-013-coral-content-plane.md) đã 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](../intake.md))

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](../intake.md))

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 qua `requireShowcaseRole` nên `toAdminAlbum` — 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:

1. **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**.
2. **`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.
3. **Cột biến thể** (`*_parent` / `*_student`) phải đi qua `pick()`: 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êm `internal_note` và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`/`_student` trần, và bắt nhầm ba module
  vô can: `learner_parent` là một giá trị `visibility` của bảng `interactions`. Nay nhận bằng
  **cặp** — `title_parent` chỉ tính khi `title_student` cũ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](../intake.md))

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:

1. **Không còn xoá ở giao diện** — cả album lẫn ảnh. Endpoint API vẫn giữ, chỉ giao diện bỏ.
2. **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.
3. **Đă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](../intake.md))

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](../intake.md))

**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](../intake.md)).** 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](../intake.md))

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](../intake.md))

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](../intake.md))

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.**

1. Vùng đọc theo đường dẫn: `/parents/**`, `/for-parents` đọc `audience=parent`; `/students/**`,
   `/for-students` đọc `audience=student`; còn lại `public`. Album chỉ dành cho bố mẹ không lên trang học sinh.
2. Khoá chương trình là đoạn đầu của đường dẫn sau tiền tố vai (`/parents/ielts` ra `ielts`). 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-teen` lấy ảnh album `ai-teen-class-2026`).
3. 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`).
