Appearance
ODP Preview Contract (v1)
Hợp đồng giữa admin và website consumer để xem trước nội dung chưa xuất bản (draft item hoặc draft version đang chờ duyệt).
Admin sinh URL:
{website_url}{path}?odp_preview=1[&odp_version=<uuid>]| Param | Ý nghĩa với website |
|---|---|
odp_preview=1 | Bỏ mọi filter status, tắt cache/ISR, render banner "Preview mode", trả noindex |
odp_version=<uuid> | Đọc item kèm delta của version đó thay vì bản đã publish |
Prefix odp_ để không đụng query param sẵn có của website.
Có 3 loại "chưa publish" — đừng gộp
| Loại | Dữ liệu nằm ở đâu | Website đọc được? |
|---|---|---|
(a) status = draft trên item | row thật trong collection | ✅ chỉ cần bỏ filter status (+ role có quyền read row đó) |
| (b) draft/pending version (approval) | odp_versions.delta (JSON); row item có thể chưa tồn tại | ✅ nhưng cần ?version= hoặc tự merge, và cần quyền đọc version |
| (c) đang nhập dở, chưa lưu | RAM của browser admin | ❌ với bất kỳ cơ chế HTTP nào — phải lưu draft trước |
Nút "Visual editing" của admin là kênh duy nhất hiển thị (a) và (b). Nút "Mở trên website" chỉ hiện với item đã published và luôn là URL sạch — cố ý không có nút nào sinh ra link draft copy được.
Phía backend — GET /items/:collection/:pk?version=<uuid>
Trả item với delta của version phủ lên trên. Shape response không đổi ({ data }).
Quyền: caller phải đọc được odp_versions (đi qua VersionsService.readOne nên RBAC tự enforce). Thiếu quyền → 403, không im lặng trả bản published.
Ràng buộc bảo mật: version phải thuộc đúng collection + item của path, nếu không → 403. Không có guard này thì ?version= biến thành IDOR đọc delta của bất kỳ item nào.
| Trường hợp | Kết quả |
|---|---|
| version hợp lệ | 200, item merge delta; primary key luôn giữ giá trị thật |
| version của item/collection khác | 403 |
| version không tồn tại | 403 — không phải 404, theo convention chung của API (không leak sự tồn tại của record) |
| version trỏ item chưa có row | 200, trả delta + PK |
version không phải UUID | 400 INVALID_QUERY |
không truyền version | hành vi cũ, không đổi |
Không hỗ trợ version cho readByQuery (list) ở v1 — filter/sort trên delta là hố sâu (YAGNI).
?version= ở bản cũ là no-op
Trước khi có tính năng này, ?version= bị sanitize-query loại bỏ như key lạ và request rơi về bản main im lặng. Consumer nào đã code sẵn ?version= với try/catch fallback thì chưa từng thực sự xem được draft.
Phía website consumer
Bắt buộc, không có ngoại lệ
- Bỏ filter
statuskhiodp_preview=1— cả trên item và trên các collection quan hệ (category…). Nếu lookup theo slug cũng filterstatus: publishedthì bài draft 404 trước khi kịp merge. - Cache key phải chứa param preview. Dùng chung cache entry với bản thường = trả draft cho khách thường (hoặc ngược lại). Kiểm tra cả cache của framework (
useAsyncDatakey) và cache HTTP/CDN. Cache-Control: no-storecho response preview,noindexcho HTML.frame-ancestorscủa website phải chứa origin của admin để iframe VE nhúng được. So sánh origin là so chuỗi chính xác:http://localhost:3100≠http://127.0.0.1:3100. Admin dev làhttp://localhost:3100(Vite bind::1,127.0.0.1không vào được) — dùng đúng chuỗi đó.
Đọc version: phải dùng app token server-side
GET /versions/:id cần app permission versioning:view. Tuyệt đối không cấp quyền này cho public role — mọi khách Internet sẽ đọc được toàn bộ draft. Dùng một token server-side của role tối thiểu, và không bao giờ để token đó lọt ra client (runtimeConfig.public, biến NUXT_PUBLIC_*…).
Nếu website chưa có app token thì phải degrade an toàn: bỏ qua odp_version và render bản published, không crash và không thử đường khác.
Ví dụ (Nuxt BFF)
ts
// server/api/news/[slug].get.ts
const query = getQuery(event)
const preview = readPreviewRequest(query) // { enabled, versionId }
if (preview.enabled) markPreviewResponse(event) // no-store + X-Robots-Tag
const statusFilter = preview.enabled ? {} : { status: { _eq: 'published' } }
let item = (await userApi.request(readItems('news', {
filter: { id: { _eq: newsId }, ...statusFilter },
// …
})))?.[0] ?? null
// Version merge: item có thể null (version trỏ row chưa tạo) → merge TRƯỚC khi quyết định 404
if (preview.enabled) item = await mergeVersionDelta(event, 'news', newsId, item, preview.versionId)
if (!item) throw createError({ statusCode: 404 })⚠️ Giới hạn đã biết: merge nông và field quan hệ
delta của admin là snapshot của toàn bộ form: {...initialValues, ...edits}. Field không sửa mang shape đọc (render được), nhưng field vừa sửa có thể mang shape ghi — { create: [...], update: [...], delete: [...] } cho o2m/m2m.
Merge nông ({...item, ...delta}) không biến shape ghi thành shape đọc. Kiểm chứng bằng integration test trên collection có translations + m2m (service/api/test/e2e/items-version-preview.test.ts):
| Delta | Kết quả |
|---|---|
Scalar (title, status…) | ✅ đúng |
| Quan hệ không sửa (read-shaped) | ✅ translations/tags vẫn là array, render được |
| Quan hệ vừa sửa (write-shaped) | ❌ website nhận {create,update,delete} thay vì array |
Nghĩa là: preview phản ánh đúng thay đổi scalar, nhưng KHÔNG phản ánh việc thêm/xoá/sửa quan hệ chưa duyệt. Đây không phải bug của merge — sửa cho đúng cần áp delta qua ItemsService trong một transaction rồi đọc và rollback (tái dùng đường promote()), tốn hơn nhiều. Chưa làm.
Đừng "vá" bằng cách reshape trong merge: update/delete cần biết trạng thái hiện tại của junction để tính ra kết quả, không suy được từ delta.
⚠️ Visual editing hiện GHI THẲNG vào item, bỏ qua approval
VisualEditingForm dùng useItem (PATCH /items/...), không dùng useItemVersioned. Với collection bật approval:
- sửa trong VE = publish ngay, không qua duyệt;
- editor chỉ có quyền ghi
odp_versions(không có quyền ghi item) sẽ 403 khi save trong VE.
Nên chỉ mở VE cho role thực sự có quyền ghi item. Việc chuyển VE sang useItemVersioned nằm ở một plan riêng.