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 |
| 16 | c_contact_duplicates |
table | Phát hiện trùng lặp: cặp a_id/b_id, score, level, reasons, status |
| 17 | c_contact_merges |
table | Audit merge: master_id, source_id, snapshot trước gộp, field override, merged_by/at |
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: contacts:active, contacts:inactive, contacts:merged (contact bị gộp vào cái khác) |
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 |
user_id |
uuid, nullable | FK tới odp_users (nếu contact này là agent hoặc user) — unique partial index, SET NULL khi merge loser |
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: merged_into (id contact master khi bị gộp), merged_at (timestamp) |
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).
Merge & dedupe
Phần tiêu đề “Merge & dedupe”Phát hiện trùng lặp được thực hiện bởi dịch vụ duplicate-detector.ts:
- Chuẩn hoá tên (NFD, bỏ dấu, sort token), email (lower/trim), phone (chỉ chữ số, E.164 chuẩn)
- Ghi cặp trùng vào
c_contact_duplicatesvới score (0–100), level (exact/high/medium/low), lý do
Gộp contact thực hiện bằng mergeContacts() (backend) hoặc BFF wrapper:
| Endpoint | Phương thức | Mục đích |
|---|---|---|
/contacts/duplicates/check |
POST | Kiểm tra trùng lặp input (tên/email/phone), trả danh sách candidate |
/contacts/duplicates?status=open&limit=10 |
GET | Liệt kê cặp trùng mở, phân trang |
/contacts/duplicates/scan |
POST | Quét toàn bộ, upsert cặp open |
/contacts/:id/duplicates |
GET | Kiểm tra duplicate của 1 contact |
/contacts/duplicates/:pairId/dismiss |
POST | Đánh dấu cặp không phải trùng |
/contacts/merge/preview |
POST | Xem trước kết quả gộp: fields khác, references sẽ chuyển, blockers |
/contacts/merge |
POST | Thực hiện gộp N contact (1 master, N-1 source) |
/contacts/:id/absorb |
POST | Gộp form dự thảo vào contact (khi phát hiện trùng khi tạo mới) |
Registry FK — file backend/extensions/contacts/src/endpoints/services/contact-references.ts:
- Nguồn sự thật duy nhất cho FK repoint (merge) và cascade-delete
- Mỗi FK khai báo:
onMerge(repoint / dedupe-junction / keep-master / employment),onDelete(cascade / nullify),optional(nếu bảng có thể chưa tồn tại) - Giữ consistency giữa 2 phép toán: gộp contact và xoá contact
Contact loser sau gộp:
- Status →
contacts:merged - Metadata →
{ merged_into: <master_id>, merged_at: <iso-timestamp> } user_id→ NULL- Ẩn khỏi danh sách mặc định, chỉ hiện nếu filter
status=contacts:merged - Xoá vĩnh viễn sau 30 ngày bởi purge schedule (kỹ thuật: cron job
15 3 * * *, delete cascade qua contact-references)
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Gộp contact trùng lặp (user guide) — wizard 3 bước, entry points, alias email/phone
- 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