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

Kiến trúc tổng thể

┌─────────────────────────────────────────────────────────────┐
│ 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.

  • Thư mụcbackend/extensions/contacts/
    • Thư mụcsrc/
      • Thư mụcendpoints/
        • index.ts defineEndpoint → registerRoutes
        • routes.ts mọi route + guard withAccess
        • context.ts AppContext + createItemsService (ép admin:true)
        • Thư mụccontrollers/ contacts, bulk, notes, profile, social-links, employments, intakes, meta, settings
        • Thư mụcservices/
          • contact-normalizer.ts chuẩn hoá output (stringify id, gom tag/type)
          • detail-sync.ts đồng bộ primary_email/phone/address ↔ bảng con
          • cascade-delete.ts xoá mọi bản ghi con khi xoá contact
          • m2m-hydrator.ts hydrate tags/types (junction) + organization_name
          • tag-resolver.ts find-or-create tag theo slug/name
          • type-resolver.ts resolve type string → shared_types_id
          • app-access.ts validateAppAccess (permission cache 60s)
      • hooks/index.ts filter/action — auto display_name, employment ↔ denormalize sync
  • Thư mụcadmin/packages/contacts/
    • Thư mụcapp/ pages · components · composables · config (layouts) · data (wizard schema)
    • Thư mụcserver/api/ BFF routes (contacts, contacts-fields, intakes, kinds, statuses, types, tags, settings)
    • Thư mụci18n/ en.json, vi.json

Đọ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+position

Contacts 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ạ.

  • Extension load một lần lúc boot; sửa backend phải rebuild dist + restart process — không có hot reload, giống mọi extension ODP khác.
  • BFF là ranh giới bắt buộc. UI không bao giờ gọi thẳng :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.
  • Đổi ODP: directus_*odp_*. Bảng hệ thống là odp_users, odp_files, odp_access, odp_policies… không còn tiền tố directus_.