Skip to content

ODP Preview Contract (v1)

Hợp đồng giữa adminwebsite 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=1Bỏ 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ạiDữ liệu nằm ở đâuWebsite đọc được?
(a) status = draft trên itemrow 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ưuRAM 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ợpKế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ác403
version không tồn tại403khô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ó row200, trả delta + PK
version không phải UUID400 INVALID_QUERY
không truyền versionhà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ệ

  1. Bỏ filter status khi odp_preview=1 — cả trên item và trên các collection quan hệ (category…). Nếu lookup theo slug cũng filter status: published thì bài draft 404 trước khi kịp merge.
  2. 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 (useAsyncData key) và cache HTTP/CDN.
  3. Cache-Control: no-store cho response preview, noindex cho HTML.
  4. frame-ancestors củ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:3100http://127.0.0.1:3100. Admin dev là http://localhost:3100 (Vite bind ::1, 127.0.0.1 khô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):

DeltaKế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.

ODP Internal API Documentation