xforge · tài liệu FE

CMS / admin của khách #

Giao diện quản trị của khách (nhân viên của từng database) + màn hình module: XforgeClient (sdk/api.ts), token nhân viên, /api/v1/*. Phần chung (khởi tạo client, lỗi, danh sách, ngôn ngữ): common.md. Luồng riêng của từng module (thu tiền, giỏ…): FE.md của module đó.


1. Đăng nhập (nhân viên) #

1.1. Mật khẩu → (chọn store) → (MFA) → token #

login({ body: { login, password } })            ← header x-store nếu đã biết store
  │
  ├─ 400 store_required ── details.stores = [{ code, name }]
  │      → bộ chọn store → store = code → gọi login lại (giữ mật khẩu trong bộ nhớ)
  │
  ├─ data.mfaRequired = true ── { ticket, methods: ["totp" | "passkey" | "recovery_code"], sms, expiresIn }
  │      → màn nhập mã (hạn 5 phút):
  │        verifyMfa({ body: { ticket, code } })          ← TOTP 6 số
  │        verifyMfa({ body: { ticket, recoveryCode } })  ← mã khôi phục (dùng 1 lần)
  │        sms = true → nút "Gửi mã qua SMS":
  │          sendMfaSmsCode({ body: { ticket } })            ← 202, mã 6 số hạn 5 phút
  │          verifyMfa({ body: { ticket, smsCode } })
  │        + rememberDevice: true                         ← ô "ghi nhớ thiết bị này" (§1.10)
  │      → sai: 401 invalid_mfa_code (đoán nhiều → 429)
  │
  └─ data = { accessToken, refreshToken, store, … }
         → session.set({ accessToken, refreshToken }); store = data.store

Sau đăng nhập: getMe() → { login, name, email, emailVerifiedAt, superuser, database, store, roles, permissions }.

1.2. Đăng nhập bằng passkey (không mật khẩu) #

Dùng API WebAuthn JSON của trình duyệt (Chrome / Edge 129+, Safari 18+, Firefox 119+):

const { data: o } = await api.getPasskeyLoginOptions({ body: { login } }); // login tuỳ chọn
const cred = await navigator.credentials.get({
  publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(o.publicKey),
}) as PublicKeyCredential;
const { data } = await api.verifyPasskeyLogin({
  body: { challengeId: o.challengeId, credential: cred.toJSON() },
});
session.set({ accessToken: data.accessToken, refreshToken: data.refreshToken });

Passkey = cả hai yếu tố (không hỏi mã MFA), và vẫn vào được khi mật khẩu đang bị khoá.

1.3. Đổi store #

bộ chọn store: listMyStores() → [{ code, name }]
  → store = code   (đổi header x-store)
  → request kế tiếp: 401 token_store_mismatch → SDK tự switchStore + chạy lại

Không cần gọi switchStore tay; refresh token giữ nguyên (nhiều tab đổi store song song không giẫm nhau).

1.4. Đăng xuất #

logout({ body: { refreshToken } }) (thu hồi phiên này) rồi session.set(null).

1.5. Quên mật khẩu #

Server gửi link PASSWORD_RESET_URL (vd https://admin.example.com/reset-password?token={token}) — FE dựng trang đó. Ngôn ngữ thư theo header Accept-Language của request (vi / en, mặc định en).

 Màn "Quên mật khẩu" (không cần đăng nhập, vẫn gửi x-database / x-store như login)
   forgotPassword({ body: { login } })        ← login HOẶC email
     → 202 LUÔN (kể cả login không tồn tại) → hiện "Nếu tài khoản có email,
       bạn sẽ nhận được link trong vài phút"
     → 429 too_many_attempts → đếm ngược details.retryAfter
     → 503 / 403 password_reset_disabled → ẩn link "Quên mật khẩu" (biết trước
       qua getPublicConfigs()["core.auth_policy"].passwordReset, §1.7)

 Email ──► https://admin.example.com/reset-password?token=XYZ   (hạn 30 phút, 1 lần)

 Trang reset-password (đọc token từ query, KHÔNG gọi API khi chỉ mở trang)
   user nhập mật khẩu mới (≥ passwordMinLength của core.auth_policy, §1.7)
     → resetPassword({ body: { token, password } })
     → 204 → về màn đăng nhập ("Đã đổi mật khẩu")
     → 401 reset_token_invalid → "Link đã hết hạn hoặc đã dùng" + nút gửi lại
     → 429 too_many_attempts → đếm ngược

1.6. Xác minh email #

 getMe() → emailVerifiedAt === null && email !== null → hiện banner "Xác minh email"
   requestEmailVerification()                 ← cần đăng nhập
     → 202 → "Đã gửi link tới <email>"
     → 409 email_missing / email_already_verified · 429 · 503 email_verification_disabled

 Email ──► EMAIL_VERIFY_URL, vd https://admin.example.com/verify-email?token=XYZ  (hạn 24 giờ)

 Trang verify-email (không cần đăng nhập)
   confirmEmail({ body: { token } })
     → 204 → "Email đã được xác minh" (đang đăng nhập → getMe() lại)
     → 401 verify_token_invalid → link sai / đã dùng / hết hạn / email đã đổi

Admin đổi email của user (updateUser({ body: { email } })) → emailVerifiedAt về null, link đã gửi tới địa chỉ cũ chết.

1.7. Cấu hình công khai cho màn đăng nhập (ADR-0028) #

getPublicConfigs() (không cần token, gửi x-database / x-store như login) trả các cấu hình public của tenant, vd:

{
  "core.auth_policy": {
    "passwordMinLength": 12,
    "passwordReset": true,
    "emailVerification": true
  },
  "core.customer_auth": { "signup": true, "...": "..." }
}

Dùng để ẩn link "Quên mật khẩu", báo độ dài mật khẩu tối thiểu trước khi gửi. Server vẫn kiểm (validation_failed, rule min_length, requirement = độ dài).

1.8. Đổi mật khẩu, phiên (ADR-0030) #

 Đổi mật khẩu (đang đăng nhập)
   changePassword({ body: { password, newPassword, code? | recoveryCode? | passkey? } })
     ← mật khẩu cũ + yếu tố thứ hai NẾU đã bật MFA (như các bước step-up §4)
     → 200 TokenPair → LƯU NGAY: mọi phiên khác (cả tab / máy khác) đã chết
     → 401 invalid_credentials · 400 mfa_code_required · 400 validation_failed

 Thiết bị đang đăng nhập
   listSessions() → [{ id, startedAt, lastActiveAt, expiresAt, store, current }]
   revokeSession({ params: { id } })   → 204 (phiên đó không refresh được nữa;
                                          access token của nó còn tối đa expiresIn giây)
   revokeAllSessions()                 → 200 TokenPair → LƯU NGAY (mọi phiên khác chết ngay)

Bắt đổi mật khẩu (ADR-0100) — admin tạo / sửa user kèm passwordChangeRequired: true (mật khẩu tạm):

login(…) → TokenPair như thường (đăng nhập không đổi hình dạng)
getMe() → passwordChangeRequired: true → mở NGAY màn đổi mật khẩu, chưa tải menu
  token này chỉ gọi được: getMe, listMyStores, listSessions / revoke…,
  switchStore, changePassword, gửi lại link xác minh email
  route khác → 403 password_change_required (kể cả bật MFA, passkey, API key)
changePassword({ body: { password: <mật khẩu tạm>, newPassword } })
  → 200 TokenPair → LƯU NGAY → getMe().passwordChangeRequired = false → vào app
  → 400 password_unchanged (newPassword trùng mật khẩu tạm)
Đặt lại qua link quên mật khẩu cũng xoá cờ.

Bắt buộc MFA (ADR-0101) — core.auth_policy.requireMfa của account:

getMe() → mfaEnrollmentRequired: true → mở màn đăng ký yếu tố thứ hai
  (passwordChangeRequired cũng true → đổi mật khẩu TRƯỚC, rồi getMe lại)
  được gọi: route tài khoản như trên + setupTotp / confirmTotp,
            getPasskeyRegisterOptions / registerPasskey
  route khác → 403 mfa_enrollment_required
confirmTotp / registerPasskey → data.tokens (TokenPair) → LƯU NGAY → vào app
Gỡ yếu tố cuối khi chính sách bật → 409 last_factor

1.9. Cấu hình tenant (core.manage_settings) #

listConfigs() → mỗi mã: value hiệu lực, source (store / account / database / default), layers (giá trị + version từng tầng). Ghi CẢ object ở một tầng: setConfig({ params: { code }, body: { scope, value, version } }); resetConfig({ params: { code }, query: { scope } }) để quay về tầng trên. Field bí mật trả về "••••••••" — gửi lại nguyên chuỗi đó để giữ giá trị cũ. Lỗi: config_scope_not_allowed, account_scope_required (cần admin cấp account), database_scope_required, version_conflict, secrets_not_configured.

1.10. Ghi nhớ thiết bị (bỏ qua MFA ở lần sau) #

Màn nhập mã có ô "Ghi nhớ thiết bị này" → verifyMfa({ body: { ticket, code, rememberDevice: true } }). Response có thêm trustedDevice: { token, expiresAt } (null khi tenant tắt — getPublicConfigs() → core.trusted_devices.days === 0 thì ẩn ô này). Có deviceTokens trong client (common §1.2) thì SDK tự lưu token theo (x-database, login) và tự gửi kèm deviceToken ở login sau đó: mật khẩu đúng + token hợp lệ của CÙNG user → token luôn, không hỏi mã. Token bị từ chối (login vẫn trả mfaRequired) → SDK xoá token đã lưu.


2. Phiên #

2.1. Thời hạn #

2.2. Hai tab cùng refresh #

Tab A và B cùng thấy access hết hạn, cùng gửi một refresh token. Server cho A token mới; B nhận refresh_token_superseded trong 15 giây ân hạn → SDK của B đọc lại session (A đã lưu) và dùng tiếp. Vì vậy session phải dùng chung giữa các tab. Gửi lại refresh token CŨ sau ân hạn = coi như bị lộ → server thu hồi cả phiên.


3. Menu và quyền #

 getMe()       → permissions: ["brand.read", "core.manage_users", …], superuser
 listMenu()    → [{ key, module, group, label, permission, icon, order, screen }]

 menu quản trị    = listMenu() (đã lọc: module bật ở store, có quyền, gói có tính năng)
 hiện nút / route  ⇔ superuser || OPERATION_PERMISSIONS[op].every(p ⇒ permissions.includes(p))
import { OPERATION_PERMISSIONS } from "./sdk/api.ts";
const has = (p: string) =>
  me.permissions.includes(p as never) ||
  me.conditionalPermissions.includes(p as never);
const can = (op: keyof typeof OPERATION_PERMISSIONS) =>
  me.superuser || OPERATION_PERMISSIONS[op].every(has);
can("createUser"); // nút "Thêm user"

listMenu() (ADR-0083): mỗi module khai mục menu của mình; nhóm theo group (phân hệ: catalog, inventory…), xếp sẵn theo group → order → key. key (brand.brands, order.orders…) là hợp đồng ổn định — CMS gắn màn hình của mình theo key, bỏ qua key chưa có màn hình. label đã dịch theo x-lang; icon chỉ là tên gợi ý (null = tự chọn). Đổi store → gọi lại.

Mọi key có trong bản server (chưa lọc theo store / người dùng), kèm quyền, tính năng gói, nhãn theo ngôn ngữ: sdk/menu.json (máy đọc — vd kiểm mọi key đã có màn hình) và sdk/menu.md (bảng theo phân hệ), sinh cùng SDK.

Menu đổi khi đang mở (module bị tắt / bật cho store, gói đổi, quyền đổi): luồng realtime (§6) phát core.menu_changed → gọi lại listMenu() rồi vẽ lại; nếu đang đứng ở màn hình có key không còn trong menu thì đưa người dùng về trang chủ. Cũng gọi lại ở mỗi ready của luồng. Không mở luồng thì F5 là đủ (/menu luôn theo trạng thái hiện tại). Gọi route của module đã bị thu hồi → 404 module_not_enabled / module_not_installed, hoặc tính năng đã ra khỏi gói → 403 feature_not_in_plan: báo "tính năng không còn" và gọi lại listMenu().

Quyền tính THEO STORE đang chọn: đổi store → gọi lại getMe(). Ẩn nút chỉ là giao diện; server vẫn kiểm (403 forbidden).

ABAC (ADR-0021):

3.1. Màn hình dựng từ mô tả (ADR-0116) #

Mục menu có screen khác null thì module đã MÔ TẢ màn hình bằng dữ liệu: CMS dùng MỘT màn hình chung để vẽ, không cần code riêng cho key đó — module mới phát hành là có màn hình, không build lại CMS.

listMenu()                 → [{ key, …, screen: { type, revision } | null }]
getMenuScreen({ params: { key } })
                           → { schemaVersion, key, module, type, revision,
                               title, layout, operations }
client.call(operations[id], { params, query, body, form })   // gọi theo path

layout của crud:

Widget: tên chuỗi (text, textarea, url, email, number, qty, money, date, datetime, switch, boolean (lọc: gửi "true" / "false"), select, badge, picker, multipicker, file, image, user, translations); null = theo schema. CMS chưa có widget đó → vẽ ô mặc định theo schema (JSON Schema của field: string → ô chữ, format: date → ngày, boolean → công tắc, enum → chọn, integer / number → số), không suy được thì hiện "chưa hỗ trợ" ở ô đó. Danh mục sẽ có thêm tên.

operations[<id>]: { method, path, permissions, conditional, features, idempotent, status, bodyContentType, rawResponse, params, query, body, response, meta } — đủ để gọi qua client.call(op, …) khi SDK đang chạy chưa có method của module (tham số đường dẫn {id} lấy từ field cùng tên của bản ghi). Hiện nút theo đúng luật ở trên: superuser || op.permissions.every(has) (has gồm cả conditionalPermissions); conditional: true → có thể 403 condition_not_met theo bản ghi; features khác rỗng → chỉ hiện khi getStorePlan().features null hoặc chứa chúng. Thiếu quyền của operation trong options → ô nhập thường theo schema. Ẩn nút chỉ là giao diện, server vẫn kiểm.

Cache: mô tả không phụ thuộc người gọi (quyền lấy từ getMe()). Chỉ mở màn hình khi key có trong listMenu() của store HIỆN TẠI — đừng mở chỉ vì còn bản cache (bản cache dùng chung giữa các store). Lưu theo (key, revision, ngôn ngữ). revision đến từ listMenu().screen — mà listMenu() đã được gọi lại khi có core.menu_changed VÀ ở mỗi ready của luồng realtime (§6): revision khác bản đã lưu → gọi lại getMenuScreen. Server lên bản mới thì luồng realtime đứt rồi nối lại (ready) nên CMS thấy revision mới. Đừng dùng tên + version của module (listModules()) làm khoá cache: mô tả có thể đổi mà version giữ nguyên (đổi nhãn, đổi schema route, nâng Core).

Mọi mô tả có trong bản server (mọi ngôn ngữ): sdk/screens.json (sinh cùng SDK) — dùng để kiểm CMS đã hỗ trợ mọi type / widget đang có.

3.2. Module của cửa hàng: tắt / bật theo store (ADR-0117) #

Hai tầng: nhà cung cấp (gói, gán module) quyết module nào ĐƯỢC PHÉP; admin của khách tắt bớt cho từng cửa hàng. Màn này viết tay (Core không có mô tả màn hình cho nó), hiện khi người dùng có quyền core.manage_store_modules ở store đang chọn — admin của riêng một store chỉ đổi được store đó; admin chung đổi store khác bằng cách đổi store (§1.3). Mọi thao tác áp cho STORE ĐANG CHỌN.

listCurrentStoreModules()                          → GET  /api/v1/core/store-modules
disableCurrentStoreModules({ modules: [...] })     → POST /api/v1/core/store-modules/disable
enableCurrentStoreModules({ modules: [...] })      → POST /api/v1/core/store-modules/enable

Mỗi phần tử (cả ba route trả cùng danh sách, sau thay đổi): { name, group, summary, depends, locked, usable, reason }.

reasonNghĩaGiao diện
nullđang dùng đượccông tắc BẬT (tắt được)
disabledkhách đã tắtcông tắc TẮT (bật được)
dependency_disabledmodule nó dựa vào đang bị tắtTẮT; bật = bật kéo cả phụ thuộc
planngoài góikhoá, "nâng gói / liên hệ"
platformnhà cung cấp chưa gán cho cửa hàng nàykhoá, "liên hệ nhà cung cấp"

locked: true = module luôn bật (không có công tắc). Nhóm theo group như menu. Tên hiển thị: summary (tiếng Anh) — có thể thay bằng nhãn menu đã dịch của module.

Tắt:

Bật: kéo theo phụ thuộc khách đã tắt. Module ngoài phần được phép → 409 store_module_unavailable, details: { module, reason } (not_installed / plan / platform).

Lỗi chung của cả hai lệnh: 400 unknown_modules (details.modules — tên server không biết, vd CMS cũ hơn server); 400 store_required (chưa chọn store); 409 store_modules_unsupported — database chưa nâng cấp (trong lúc rollout): danh sách vẫn đọc được, hiện "chưa đổi được lúc này" và ẩn công tắc.

Sau thay đổi: menu tự cập nhật qua core.menu_changed (§6) — hoặc gọi lại listMenu() ngay cho người đang thao tác. Tiến trình server khác thấy thay đổi trong vòng 5 giây.


4. Cài đặt bảo mật của user (MFA) #

Thêm / gỡ yếu tố đòi xác thực lại: password + (nếu đã có MFA) MỘT trong: code (TOTP) · recoveryCode · passkey (chữ ký passkey). Thiếu → 400 mfa_code_required (user có TOTP) / mfa_proof_required (chỉ có passkey).

 Bật TOTP
   setupTotp({ body: { password, code? } }) → { secret, otpauthUri }   ← vẽ QR từ otpauthUri
   confirmTotp({ body: { code } })          → { recoveryCodes, tokens }
       recoveryCodes: hiện MỘT lần, bắt user lưu
       tokens ≠ null (yếu tố đầu tiên): phiên cũ ở mọi nơi bị thu hồi →
                                         session.set(tokens) NGAY

 Thêm passkey
   getPasskeyRegisterOptions({ body: { password, … } }) → { challengeId, publicKey }
   navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(publicKey) })
   registerPasskey({ body: { challengeId, name, credential: cred.toJSON() } })
       → { passkey, recoveryCodes, tokens }   (như trên)

 Xác thực lại bằng passkey (user chỉ có passkey)
   getStepUpPasskeyOptions() → { challengeId, publicKey }
   navigator.credentials.get(…) → passkey: { challengeId, credential: cred.toJSON() }
   rồi gửi kèm: deletePasskey({ params: { id }, body: { password, passkey } })

Trạng thái: getMfaStatus() → { enabled, totp, passkeys, recoveryCodesLeft, smsPhone }.

Số điện thoại DỰ PHÒNG (ADR-0104): chỉ thêm được khi đã có TOTP / passkey (409 mfa_sms_needs_factor); mã SMS chỉ dùng ở bước MFA lúc đăng nhập, không bật MFA, không thoả "bắt buộc MFA", không dùng để gỡ yếu tố.

setupMfaSms({ body: { password, code | recoveryCode | passkey, phone } })  → 202 (nhắn mã)
confirmMfaSms({ body: { code } })   → { phone: "84912***678" }
disableMfaSms({ body: { password, code | recoveryCode | passkey } })  → 204

Sinh lại mã khôi phục (còn ít mã / nghi bị lộ): regenerateRecoveryCodes({ body: { password, code | recoveryCode | passkey } }) → { recoveryCodes } — hiện MỘT lần; mọi mã cũ (kể cả chưa dùng) và mọi thiết bị đã ghi nhớ (§1.10) chết. Chưa có TOTP / passkey → 409 mfa_not_enabled.


5. Gói cước, khách của storefront (ADR-0025 / ADR-0027) #


6. Realtime (SSE) + Web Push (ADR-0092 / ADR-0093) #

Cập nhật trực tiếp khi CMS đang mở — một luồng cho mọi màn hình:

import { eventStream } from "./sdk/runtime.ts";

const live = eventStream(
  (lastEventId) => api.streamMyEvents({ lastEventId }),
  {
    // (Nối lại) xong: đọc lại thứ đang hiển thị — sự kiện chỉ là gợi ý.
    onOpen: () => {
      refreshBadge();
      reloadMenu();
    },
    onEvent: ({ event, data }) => {
      if (event === "core.notification") refreshBadge();
      if (event === "core.menu_changed") reloadMenu(); // listMenu() (§3)
      if (event === "stock.level_changed") reloadIfShowing(data);
    },
    onError: () => {/* 401 / 403: phiên chết hoặc API key — dừng */},
  },
);
// Đổi store / đăng xuất: live.close() rồi mở lại.

Web Push — thông báo khi tab đã đóng (chỉ thông báo module gửi kèm push: true):

const { data: cfg } = await api.getMyPushConfig();
if (cfg.enabled && "serviceWorker" in navigator) {
  const reg = await navigator.serviceWorker.register("/sw.js");
  if (await Notification.requestPermission() === "granted") {
    const sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: cfg.publicKey!, // base64url — trình duyệt nhận chuỗi
    });
    const json = sub.toJSON(); // { endpoint, expirationTime, keys }
    await api.createMyPushSubscription({
      body: {
        endpoint: json.endpoint!,
        expirationTime: json.expirationTime ?? null,
        keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
        label: "Chrome trên máy văn phòng",
      },
    });
  }
}
// sw.js — payload: { id, kind, storeId, title, link: { key, id } | null }
self.addEventListener("push", (e) => {
  const n = e.data.json();
  e.waitUntil(
    self.registration.showNotification(n.title, { tag: n.id, data: n }),
  );
});
self.addEventListener("notificationclick", (e) => {
  e.notification.close();
  const n = e.notification.data;
  // Mở màn hình theo khoá menu (ADR-0083) — cùng cách xử lý `link` ở hộp thư.
  e.waitUntil(
    clients.openWindow(
      `/go?store=${n.storeId}&key=${n.link?.key ?? ""}&id=${n.link?.id ?? ""}`,
    ),
  );
});

7. Xuất / nhập CSV / Excel (ADR-0094) #

Một màn hình chung cho mọi module:

const { data: sets } = await api.listDatasets(); // chỉ tập dùng được + có quyền
// Xuất: Response (file) — tải về bằng Blob
const res = await api.exportDataset({
  params: { name },
  query: { format: "xlsx" },
});
download(
  await res.blob(),
  filenameFrom(res.headers.get("content-disposition")),
);
// File mẫu để nhập (tiêu đề theo ngôn ngữ, cột bắt buộc có *)
await api.datasetImportTemplate({
  params: { name },
  query: { format: "xlsx" },
});
// Nhập: chạy thử trước, hiện lỗi theo dòng, rồi nhập thật
const file = input.files[0];
const fmt = file.name.endsWith(".xlsx") ? "xlsx" : "csv";
const dry = await api.importDataset({
  params: { name },
  query: { format: fmt, dryRun: "true" },
  raw: file,
});
// dry.data.errors: [{ line, column, rule, message }] — line 1 = dòng tiêu đề

8. Webhook gửi đi (ADR-0096) #

Màn hình "Tích hợp" của store (quyền core.manage_webhooks):

Hệ thống NHẬN (đưa cho khách tích hợp) — hợp đồng gửi đi (thêm field được, đổi / bỏ = phá):

PhầnNội dung
Body (JSON){ id, event, createdAt, storeId, data } — id = mã lần giao (gửi lại có id mới), data của sự kiện (core.ping: { message: "ping" })
X-Xforge-Eventtên sự kiện (<module>.<tên>)
X-Xforge-Delivery= id của body
X-Xforge-Signaturet=<unix giây>,v1=<hex HMAC-SHA256(secret, "<t>.<body thô>")> — khoá HMAC là NGUYÊN chuỗi whsec_… (không bỏ tiền tố)

Trả 2xx trong 10 giây = đã nhận. 5xx / 429 / 408 / quá giờ → gửi lại lùi dần (≤ 8 lần); 4xx khác → không gửi lại. Hỏng hẳn 20 lần liên tiếp → URL tự tắt.

// Kiểm chữ ký trước khi tin body — so thời gian hằng, từ chối nếu t quá 5 phút.
const [, t, v1] = /t=(\d+),v1=([0-9a-f]+)/.exec(
  req.headers["x-xforge-signature"],
)!;
const expected = hmacSha256Hex(secret, `${t}.${rawBody}`);
if (!timingSafeEqual(expected, v1) || Math.abs(Date.now() / 1000 - +t) > 300) {
  reject();
}
// Trả 2xx nhanh; xử lý nền. Trùng `id` (giao lại / thử lại) → bỏ qua.

9. Báo cáo (ADR-0097) #

const { data: reports } = await api.listReports(); // chỉ báo cáo xem được
// Form tham số dựng từ `report.params` (date / text / number / select + options;
// loại lạ sau này → hiện như ô chữ)
const params = { from: "2026-10-01", to: "2026-10-31" };
const { data } = await api.runReport({ params: { name }, body: { params } });
// data.columns (numeric / money), data.rows (khoá cột → ô), data.summary | null
await api.exportReport({ params: { name }, body: { format: "xlsx", params } }); // file
const { data: job } = await api.startReportExport({
  params: { name },
  body: { format: "xlsx", params },
});
// → chờ realtime `core.data_export` rồi `downloadDataExport({ params: { id: job.id } })`

10. Nhập chạy nền + báo cáo định kỳ (ADR-0098) #

// File lớn (> 5 000 dòng): gửi như importDataset, nhận 202 + lượt nhập
const { data: imp } = await api.startDatasetImport({
  params: { name },
  query: { format: "xlsx", dryRun: "true" },
  body: file,
});
// → chờ realtime `core.data_import` ({ importId, status })
const { data } = await api.getDataImport({ params: { id: imp.id } });
// data.status: done | failed; data.applied; data.errors (theo dòng, như nhập trực tiếp)

// Lịch báo cáo: thư tới email ĐÃ XÁC MINH của chính người dùng
await api.createReportSchedule({
  body: {
    report: name,
    format: "xlsx",
    params,
    frequency: "weekly",
    hour: 7,
    weekday: 1,
  },
});

11. In chứng từ (ADR-0099) #

const { data: printables } = await api.listPrintables(); // mẫu dùng được
// Xem: fetch (kèm token) → hiện trong iframe SANDBOX (không allow-scripts)
const res = await api.printDocument({ params: { name, id } }); // Response
iframe.setAttribute("sandbox", ""); // chỉ hiển thị; srcdoc KHÔNG nhận CSP của response
iframe.srcdoc = await res.text();
// In: tải PDF (nếu có) hoặc tải file HTML rồi người dùng mở / in — KHÔNG gọi
// print() vào iframe sandbox, và KHÔNG BAO GIỜ bật cùng lúc allow-scripts +
// allow-same-origin (vô hiệu sandbox).
// Tải PDF (chỉ khi server có dịch vụ PDF — 409 pdf_unavailable thì ẩn nút):
//   api.printDocument({ params: { name, id }, query: { format: "pdf" } })
// Gửi email (chỉ hiện khi canEmail); bật Idempotency-Key của client để bấm hai
// lần không gửi hai thư.
const { data } = await api.emailDocument({ params: { name, id } });
// data.to = địa chỉ đã che ("a***@example.com") → hiện "Đã gửi tới …"

12. Checklist trước khi ghép với server thật #

Nguồn: xforge-core: docs/fe/cms.md