Bản prototype HR từng có logic đồng bộ 2 chiều giữa HR và Contact, và cả hai phần đều chết âm thầm:
contact-sync.ts — ghi vào bảng không tồn tại
File contact-sync.ts (đã xoá) đọc/ghi bảng c_contact_types — bảng này không tồn tại trong schema thật (types thật nằm ở shared_types + junction c_contact_types_junction). Hàm syncContactTypes() được gọi trong hook hr_stage_transitions.items.create nhưng vì bảng đích sai tên, nó silently no-op — không exception, không log lỗi, chỉ đơn giản không làm gì.
Tab HR gate theo field ma
hr-extensions.ts từng đăng ký tab với visible: Boolean(ctx.contact?.hr_person_id). Nhưng c_contacts.hr_person_idchưa bao giờ được tạo trong schema (đúng quyết định D5 — xem bên dưới). Kết quả: điều kiện luôn false, tab HR không bao giờ hiện trên bất kỳ contact nào, kể cả contact đã có hr_people.
Cả 2 vấn đề trên đã được dọn sạch: contact-sync.ts không còn tồn tại trong backend/extensions/hr/src/endpoints/services/, và tab hiện đăng ký visible: () => true.
Quyết định kiến trúc: Contact = master, HR = satellite
Sự tồn tại của row hr_people với contact_id trỏ tới, đọc trực tiếp mỗi lần — không phải flag/tag lưu sẵn trên contact.
D4
Badge loại (talent/employee) hiện trên contact?
Không. Trạng thái chỉ hiện trong tab HR, đọc từ hr_people.stage. Không có projection sang shared_types.
D5
FK ngược c_contacts.hr_person_id?
Không cần, không tạo. Contact không bao giờ đọc HR; tab HR tự fetch bằng contact_id.
D6
Hook / auto-sync giữa 2 module?
Không có. Mọi ghi dữ liệu là hành động tường minh của user, xử lý trong HR.
Hệ quả chấp nhận: trạng thái HR không hiển thị ở nơi khác ngoài HR (không có badge trên danh sách contact) nếu không tự query HR riêng. Đây là trade-off có chủ đích (ưu tiên không nhân bản dữ liệu), không phải thiếu sót — nếu sau này cần hiển thị nhanh trạng thái HR trên list contact, giải pháp đúng là một endpoint batch đọc-side, không phải thêm cột/tag lưu sẵn.
Có 2 lối vào tạo hr_people, cả hai cùng đi qua một service (backend/extensions/hr/src/endpoints/services/people.service.ts), idempotent theo contact_id:
Lối 1 — Tạo từ HR (form "Add Talent"/"Add Employee")
chỉ có email/tên → find-or-create contact theo email → tạo satellite
Lối 2 — Tạo từ Contact (tab HR, nút "Add as Talent"/"Add as Employee" ở empty state)
đã có contact_id → dùng thẳng contact đó → tạo satellite
return res.status(400).send({ errors: [{ message: 'contact_id is required' }] });
}
const person = await createPersonSvc(ctx,req.accountability, { ...req.body, contact_id: contactId });
return res.status(201).send({ data: person });
};
}
Idempotency nằm ở bước 2 (findHrPersonByContact trước khi createOne) — bấm nút “Add as Employee” hai lần liên tiếp trên cùng một contact không tạo ra 2 hr_people, lần gọi sau trả về đúng satellite đã tồn tại.
Tab HR gọi endpoint này khi mount để quyết định empty hay filled — trả { data: null } (200, không phải 404) khi chưa có satellite, để phía UI dễ phân biệt “chưa có” với lỗi mạng:
Registration nằm trong hr-extensions.ts, chạy lúc plugin boot (context không có Vue component instance):
app/plugins/hr-extensions.ts (đầy đủ)
exportdefaultdefineNuxtPlugin(()=> {
const extensionStore = useExtensionStore()
// HR is a satellite of the contact (master). The tab is always present when the
// HR layer is loaded; the empty/filled state is decided inside the component by
// reading the satellite live via contact_id. No contact-type projection.
// `id` doubles as the sub-route segment (/contacts/:id/hr). `component` is passed
// as a NAME string and resolved by the contacts [tab].vue resolver in component
// context — resolveComponent() here in plugin context returns an unreliable ref.
extensionStore.register('contacts.detail.tabs', {
id: 'hr',
label: 'HR',
icon: 'i-ph-identification-badge-light',
component: 'HrContactTab',
sort: 20,
visible: ()=>true,
})
})
component: 'HrContactTab' là một chuỗi tên, không phải kết quả resolveComponent() gọi ngay trong plugin — comment trong code giải thích lý do: ở ngữ cảnh plugin (chưa render), resolveComponent() trả về một ref không đáng tin cậy. Việc resolve thật sự diễn ra phía trang chi tiết contact ([id].vue) — trong ngữ cảnh component, nơi resolveComponent() hoạt động đúng.
Component tab.vue tự quản lý toàn bộ vòng đời empty/filled/loading bằng useState theo contact.id (không phải state module toàn cục dùng chung cho mọi contact):
Sở hữu identity (c_contacts), UI shell trang chi tiết, và 2 injection point (contacts.detail.tabs, contacts.detail.actions). Không bao giờ import HR, không bao giờ đọc/ghi dữ liệu HR.
HR — satellite chủ động
Sở hữu hr_people + 22 bảng con, toàn bộ quy trình nghiệp vụ, component tab (HrContactTab), route BFF people/from-contact, people/by-contact/:id. Chủ động đăng ký chính nó vào injection point của Contacts.
Hợp đồng duy nhất giữa 2 module
String key của injection point ('contacts.detail.tabs') + FK hr_people.contact_id. Không có bảng trung gian, không có event bus, không có hook chéo module.