Bỏ qua để đến nội dung

Frontend (Nuxt layer + BFF)

pages/contacts/** + components/contacts/**
│ gọi composable (không tự $fetch)
app/composables/use-contact*.ts ← CRUD, field registry, form logic, settings
│ $fetch (cookie-auth, cùng origin)
server/api/** ← BFF: assertPermission({module:'contacts', action}) + customEndpoint proxy
│ @odp/sdk userApi.request()
backend/extensions/contacts (ODP API) ← xem [Kiến trúc tổng thể](/contacts/ky-thuat/kien-truc/)
  • Thư mụcapp/composables/
    • use-contacts.ts CRUD, search, bulk ops, list + counts state
    • use-contacts-api.ts Raw $fetch wrapper cho mọi route BFF
    • use-contact-form.ts Model form theo tab, validate, gom lỗi theo tab
    • use-contact-field-registry.ts Field metadata 4 collection, normalize + tweak
    • use-collection-fields.ts Fetch field theo 1 collection (useContactCollectionFields)
    • use-contact-kinds.ts Kinds từ shared_kinds
    • use-contact-statuses.ts Statuses từ shared_statuses
    • use-contact-type-registry.ts Registry types contributed từ module ngoài
    • use-contacts-settings.ts Settings module + cột danh sách (client-persist)
    • use-contact-intake.ts Triage: list, match, promote, dismiss (+ bulk)
    • use-contact-employments.ts CRUD Work History
    • use-shared-collection.ts CRUD generic cho statuses/types/tags (settings)
    • use-contacts-shortcuts.ts Phím tắt N / / / ?
    • use-extension-store.ts Re-export useExtensionStore từ host, fallback no-op

Vì sao có useContactCollectionFields thay vì useCollectionFields

Phần tiêu đề “Vì sao có useContactCollectionFields thay vì useCollectionFields”
admin/packages/contacts/app/composables/use-collection-fields.ts:1-5
// NOTE: named `useContactCollectionFields` (not `useCollectionFields`) to avoid an
// auto-import collision with `@odp/workflow`'s `useCollectionFields` (different return
// shape) which otherwise shadows this one and breaks the contact field registry.

Đây là ví dụ thực tế của rủi ro auto-import trong Nuxt 4 layer: hai package cùng đăng ký một tên composable, cái load sau âm thầm đè cái trước — không có lỗi build, chỉ có bug lúc chạy.

Field Registry — field metadata theo schema thật, không hardcode

Phần tiêu đề “Field Registry — field metadata theo schema thật, không hardcode”

useContactFieldRegistry() fetch field metadata cho 4 collection (contacts, contact_profiles, contact_intakes, contact_settings) từ BFF, rồi áp một lớp normalize + tweak trước khi form dùng:

admin/packages/contacts/app/composables/use-contact-field-registry.ts:13-26
const FIELD_TWEAKS: Partial<Record<CollectionKey, Record<string, { interface?: string, options?: Record<string, any>, readonly?: boolean }>>> = {
contacts: {
// Read-only in forms: current organization is managed via Work History (employments)
organization_id: { options: { template: "{{display_name}}" }, readonly: true },
date_of_birth: { interface: "date-picker" },
nationality: { interface: "select-country" },
country: { interface: "select-country" },
notes: { interface: "input-multiline" },
},
contact_profiles: {
handling_notes: { interface: "input-multiline" },
},
}

interface: "date-picker""select-country"component module-local đăng ký toàn cục qua app/plugins/interfaces.ts — không có sẵn ở core UI, module tự bổ sung.

server/api/contacts-fields/[collection].get.ts nhận tên rút gọn và map sang tên collection thật trước khi gọi backend:

backend/extensions/contacts/src/endpoints/controllers/meta.controller.ts:4-9
const COLLECTION_MAP: Record<string, string> = {
contacts: 'c_contacts',
contact_profiles: 'c_contact_profiles',
contact_intakes: 'c_contact_intakes',
contact_settings: 'c_contact_settings',
};

Khác với Helpdesk (không có remap, dùng allowlist tên thật) — Contacts chọn cách map tên ngắn để client không phải gõ tiền tố c_contact_ khắp nơi.

Contacts theo đúng convention FormRoot + useFieldBuilder bắt buộc cho mọi form (không có <input>/<UFormField> tay). Layout tách khỏi field list:

  • Thư mụcapp/config/
    • form-layouts.ts PERSON_FORM_LAYOUT, ORGANIZATION_FORM_LAYOUT, PROFILE_FORM_TAB
    • info-layouts.ts PERSON_INFO_LAYOUT, ORGANIZATION_INFO_LAYOUT, PROFILE_INFO_LAYOUT (đọc)
    • column-renderers.ts Component render từng cột bảng danh sách

Mỗi layout là mảng tab > section > tên field. useContactForm() build field thật cho tab đang active bằng cách ghép layout với field registry:

admin/packages/contacts/app/composables/use-contact-form.ts:34-49
const activeFields = computed<Field[]>(() => {
if (activeTab.value === "profile") {
const profileFields = getAllFields("contact_profiles")
return buildTabFields(PROFILE_FORM_TAB, profileFields)
}
const contactFields = getAllFields("contacts")
const tab = activeLayout.value.tabs.find(t => t.id === activeTab.value)
const built = buildTabFields(tab, contactFields)
// Create: organization_id editable (seeds Work History on save).
// Edit: hide entirely — managed via Work History.
if (options.allowOrganizationSelect) return built.map(withEditableOrganization)
return built.filter(f => f.field !== "organization_id")
})

activeLayout chọn PERSON_FORM_LAYOUT hay ORGANIZATION_FORM_LAYOUT dựa trên model.value.type — cùng một component form (ContactsContactForm) render layout khác nhau tuỳ kind.

BFF pattern: assertPermission + customEndpoint proxy

Phần tiêu đề “BFF pattern: assertPermission + customEndpoint proxy”
server/api/contacts/index.get.ts (đầy đủ)
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => {
assertPermission(event, { module: "contacts", action: "read" })
const userApi = event.context.userApi
const query = getQuery(event)
const result = await userApi.request(customEndpoint({
path: "/contacts/",
method: "GET",
params: query,
}))
return result
})

Route counts tự bọc lại envelope tương tự Helpdesk (useContactsApi().counts() tolerate cả hai hình dạng { data } hoặc object trần — phòng trường hợp SDK unwrap tự động).

Settings — state client-persist, không phải server settings

Phần tiêu đề “Settings — state client-persist, không phải server settings”

useContactsSettings() giữ 2 loại state khác hẳn nhau:

Server-backed

defaultStatus, nameComposition — đọc/ghi qua /api/settings (singleton c_contact_settings), dùng chung mọi user.

Client-only (localStorage)

columnWidths, pinnedIds — plugin contacts-settings-persist.client.ts đọc/ghi localStorage key contacts-settings, mỗi trình duyệt tự nhớ riêng, không đồng bộ giữa các máy.

contactTypes (danh sách type hiển thị sidebar/filter) là hợp nhất giữa shared_types từ backend và bất kỳ type nào module khác register() qua useContactTypeRegistry() — backend thắng khi trùng key, key chỉ-contributed được nối thêm vào cuối.