Data model
Toàn bộ collection
Phần tiêu đề “Toàn bộ collection”Tất cả PK là integer auto-increment. Mọi FK trỏ c_contacts.id cũng là integer.
| # | Collection | Loại | Mô tả |
|---|---|---|---|
| 1 | contacts |
label (không có bảng) | Thư mục gom nhóm — đã tồn tại sẵn trên backend, wizard không tạo |
| 2 | c_contacts |
table | Bảng trung tâm — person + organization cùng bảng, phân biệt bằng type |
| 3 | c_contact_profiles |
table | 1:1 hồ sơ CRM (giao tiếp, quan hệ, tính cách, mối quan tâm) |
| 4 | c_contact_social_links |
table | O2M — platform + handle + url |
| 5 | c_contact_notes |
table | O2M — ghi chú tự do, có author |
| 6 | c_contact_intakes |
table | Hàng đợi triage từ nguồn ngoài |
| 7 | c_contact_settings |
singleton | default_status, name_composition |
| 8 | c_contact_emails |
table | O2M — nhiều email/contact, cờ is_primary |
| 9 | c_contact_phones |
table | O2M — nhiều phone/contact, cờ is_primary |
| 10 | c_contact_addresses |
table | O2M — nhiều địa chỉ/contact, cờ is_primary |
| 11 | c_contact_employments |
table | Lịch sử làm việc: contact_id (person) × organization_id (org) |
| 12 | c_contact_activities |
table | O2M nhật ký hoạt động (note/call/email/meeting) |
| 13 | c_contact_activity_types |
table | Lookup loại hoạt động (key unique) |
| 14 | c_contact_tags_junction |
junction | M2M c_contacts ↔ shared_tags |
| 15 | c_contact_types_junction |
junction | M2M c_contacts ↔ shared_types |
Bảng shared_* dùng chung, cũng do wizard Contacts tạo (nhưng không thuộc sở hữu riêng của module):
| Collection | Scope | Mô tả |
|---|---|---|
shared_tags |
contacts: |
Tag tự do |
shared_kinds |
không scope | person / organization — dùng chung toàn hệ thống |
shared_statuses |
contacts: |
Trạng thái vòng đời contact |
shared_types |
contacts: |
Loại quan hệ (customer/lead/vendor/partner/investor) |
Collection dùng chung tham chiếu tới nhưng không thuộc Contacts:
odp_users—triaged_by(intake),author_id(activity)
FK ordering — vì sao thứ tự này bắt buộc
Phần tiêu đề “FK ordering — vì sao thứ tự này bắt buộc”Setup Wizard tạo theo sort khai báo trong contacts-schema.ts: c_contacts trước tiên (không phụ thuộc gì), rồi mọi bảng O2M phụ thuộc nó (profiles, social_links, notes, emails, phones, addresses, activities), rồi employments (phụ thuộc c_contacts hai lần — cả contact_id lẫn organization_id), rồi 2 junction M2M, cuối cùng 4 bảng shared_*.
Thư mụcadmin/packages/contacts/
- app/data/contacts-schema.ts ModuleSchema — collections + fields + relations + seed, chạy qua Setup Wizard
c_contacts — bảng trung tâm
Phần tiêu đề “c_contacts — bảng trung tâm”| Field | Type | Ghi chú |
|---|---|---|
type |
string, required | person | organization |
display_name |
string, required | Tên hiển thị — tự ghép từ first_name+last_name nếu để trống (hook items.create/items.update) |
primary_email, primary_phone |
string | Đồng bộ 2 chiều với bản ghi is_primary ở c_contact_emails/phones — xem detail-sync.ts |
organization_id, position |
integer, string | Denormalize từ employment is_current: true — backend hook tự tính lại, form để readonly khi sửa |
types |
alias, special: ["m2m"] |
Join qua c_contact_types_junction |
tags |
alias, special: ["m2m"] |
Join qua c_contact_tags_junction |
metadata |
json, hidden | Payload mở rộng (chưa dùng rộng rãi) |
Detail records — email / phone / address
Phần tiêu đề “Detail records — email / phone / address”3 bảng O2M (c_contact_emails, c_contact_phones, c_contact_addresses) cho phép nhiều bản ghi/contact với type (work/personal/billing…) và cờ is_primary. detail-sync.ts là nơi giữ đồng bộ:
export async function syncPrimaryEmail(ctx: AppContext, contactId: number, email: string) { await syncPrimaryDetail(ctx, 'c_contact_emails', 'address', contactId, email);}- Create:
createPrimaryRecords()— nếu body cóprimary_email/primary_phone, tạo luôn bản ghi conis_primary: true. - Update:
syncPrimaryEmail/syncPrimaryPhone/syncPrimaryAddress— tìm bản ghiis_primary: truehiện có; có thì update, chưa có thì tạo mới (chỉ khi giá trị không rỗng).
c_contact_employments — quan hệ person ↔ organization
Phần tiêu đề “c_contact_employments — quan hệ person ↔ organization”| Field | Ghi chú |
|---|---|
contact_id |
M2O → c_contacts (person) |
organization_id |
M2O → c_contacts (organization) — cùng bảng, phân biệt bằng vai trò field |
is_current |
boolean — chỉ một true tại một thời điểm cho mỗi contact_id |
Hook syncEmployment() enforce “chỉ một current” bằng raw Knex updateMany (cố ý không dùng ItemsService.updateMany — sẽ re-emit items.update và đệ quy vào chính hook này).
Cascade delete
Phần tiêu đề “Cascade delete”Xoá một contact xoá theo mọi bảng con trước khi xoá hàng c_contacts chính (cascade-delete.ts):
const CHILD_COLLECTIONS = [ 'c_contact_emails', 'c_contact_phones', 'c_contact_addresses', 'c_contact_activities', 'c_contact_social_links', 'c_contact_profiles', 'c_contact_notes', 'c_contact_tags_junction', 'c_contact_types_junction',];c_contact_employments được xử lý riêng vì cả hai cột contact_id và organization_id đều NOT NULL — xoá contact đang là employer của ai đó cũng phải xoá luôn employment (không thể set null).
2 junction M2M — hydrate + ghi thủ công
Phần tiêu đề “2 junction M2M — hydrate + ghi thủ công”| Junction | Nối | Field alias tương ứng |
|---|---|---|
c_contact_tags_junction |
c_contacts ↔ shared_tags |
c_contacts.tags |
c_contact_types_junction |
c_contacts ↔ shared_types |
c_contacts.types |
m2m-hydrator.ts đọc cả hai bằng một query whereIn mỗi junction (không N+1):
const [tagRows, typeRows] = await Promise.all([ ctx.database('c_contact_tags_junction').whereIn('c_contacts_id', ids) .select('c_contacts_id', 'shared_tags_id'), ctx.database('c_contact_types_junction') .join('shared_types', 'c_contact_types_junction.shared_types_id', 'shared_types.id') .whereIn('c_contacts_id', ids) .select('c_contacts_id', 'shared_types.slug as shared_types_slug'),]);Ghi (create/update contact) đi qua resolveTagsForWrite/resolveTypesForWrite — nhận string (tag name/slug hoặc type slug) từ client, resolve/tạo mới hàng shared_* tương ứng, rồi trả về mảng { shared_tags_id }/{ shared_types_id } cho ItemsService tự M2M-sync (khác với Helpdesk, ở đây có để ItemsService sync junction, vì input luôn là toàn bộ tập đích chứ không phải patch một phần).
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Kiến trúc tổng thể — bức tranh 4 tầng đầy đủ
- Frontend (Nuxt layer + BFF) — field registry đọc field metadata từ đâu
- Mở rộng & tuỳ biến — thêm field/sub-collection theo đúng quy trình