1 · UI (Vue/Nuxt)
Pages + components + composables trong Nuxt layer. Không gọi thẳng backend — mọi request đi qua BFF cùng origin.
┌─────────────────────────────────────────────────────────────┐│ Admin console (Nuxt 4, NuxtUI v4) ││ admin/packages/contacts ← Nuxt layer ││ app/ pages · components · composables (14+ composables) ││ ▲ ││ │ $fetch (cookie-auth) ││ server/api/** ← BFF: assertPermission + customEndpoint │└─────────┼─────────────────────────────────────────────────────┘ │ @odp/sdk (customEndpoint, userApi.request) ▼┌─────────────────────────────────────────────────────────────┐│ ODP API server (@odp/api, Fastify + Knex) ││ backend/extensions/contacts ← bundle extension ││ endpoints/ routes · controllers · services ││ hooks/ filter/action lifecycle (denormalize, seed) ││ ▲ ││ │ ItemsService / FieldsService / getSchema / database ││ ▼ ││ ODP core: permissions · activity · events · Knex · storage │└─────────────────────────────────────────────────────────────┘ │ ▼ Postgres/MySQL/SQLite (c_contact_* + shared_* + odp_*)1 · UI (Vue/Nuxt)
Pages + components + composables trong Nuxt layer. Không gọi thẳng backend — mọi request đi qua BFF cùng origin.
2 · BFF
server/api/** proxy sang /contacts/* của backend, đính token từ
cookie, chặn quyền bằng assertPermission.
3 · Extension
Endpoints (HTTP) + hooks (lifecycle: denormalize employment, auto display_name). Toàn bộ logic nghiệp vụ server-side nằm ở đây.
4 · ODP core
ItemsService (permission + activity + events), Knex, FieldsService cho field-metadata theo schema thật.
Đọc danh sách contact (mở trang /contacts):
UI useContacts().fetchContacts() → $fetch GET /api/contacts?page&limit&search&contact_type&rel (BFF) → assertPermission({module:'contacts', action:'read'}) → userApi.request(customEndpoint(GET /contacts/)) (SDK) → withAccess(ctx,'contacts','read', ContactsController.list) → resolve filter (kind/status/search/rel — rel query junction TRƯỚC khi phân trang) → ItemsService.readByQuery + MetaService (total_count/filter_count) → hydrateM2M (tags/types + organization_name) → normalizeContact (resolve tag id → tên hiển thị)Tạo contact mới (drawer Add):
UI ContactsContactForm → useContacts().createContact(draft) → POST /api/contacts (BFF) → ContactsController.create → resolveTagsForWrite / resolveTypesForWrite (find-or-create) → svc.createOne('c_contacts', payload) → createPrimaryRecords → tạo c_contact_emails/phones is_primary:true → syncPrimaryAddress nếu có address/city/country → hook items.create: tự ghép display_name nếu thiếu, seed employment nếu có organization_id+positionContacts là master, không phải satellite
Không giống Helpdesk (satellite tham chiếu contact_id), Contacts sở
hữu c_contacts — mọi field identity (display_name,
primary_email…) sống ở đây. Module khác chỉ được đọc/tham chiếu, không
được tự thêm cột identity trùng lặp.
Detail records đồng bộ 2 chiều với field đơn
primary_email/primary_phone/address trên c_contacts là field
tiện dụng cho form đơn giản, nhưng nguồn thật là bản ghi
is_primary: true trong c_contact_emails/phones/addresses.
detail-sync.ts giữ hai bên khớp nhau ở mọi lần ghi.
ItemsService, không raw SQL — trừ hydrate & denormalize
CRUD chính đi qua ItemsService để có permission + activity + event.
Raw Knex chỉ dùng cho hydrate M2M (whereIn gom theo Map, không N+1)
và ghi đè field denormalize (organization_id/position từ
employment) — cả hai đều có comment giải thích vì sao không dùng
ItemsService.
Trust model module-level (giống Helpdesk)
createItemsService ép admin:true; authorization dồn về guard
withAccess(module, action) ở tầng route. Không có row-scoping theo
phòng ban/owner — mọi user có contacts:read thấy toàn bộ danh bạ.
dist + restart process — không có hot reload, giống mọi extension ODP khác.:8055. Thêm route backend thì phải thêm route BFF tương ứng.shared_kinds/shared_statuses/shared_types/shared_tags dùng chung với module khác (vd HR) — Contacts chỉ được đọc/ghi phần scope contacts: (trừ kinds, không scope). Đừng xoá thẳng một hàng shared_* mà không kiểm tra module khác có đang dùng.directus_* → odp_*. Bảng hệ thống là odp_users, odp_files, odp_access, odp_policies… không còn tiền tố directus_.c_contact_* + quan hệ