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

Data model

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
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)

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ộ:

backend/extensions/contacts/src/endpoints/services/detail-sync.ts
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 con is_primary: true.
  • Update: syncPrimaryEmail/syncPrimaryPhone/syncPrimaryAddress — tìm bản ghi is_primary: true hiệ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).

Xoá một contact xoá theo mọi bảng con trước khi xoá hàng c_contacts chính (cascade-delete.ts):

backend/extensions/contacts/src/endpoints/services/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).

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):

backend/extensions/contacts/src/endpoints/services/m2m-hydrator.ts
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).

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_duplicates vớ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)