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

Tích hợp Contacts

Trước đây vs bây giờ — một bài học về “gate theo field không tồn tại”

Phần tiêu đề “Trước đây vs bây giờ — một bài học về “gate theo field không tồn tại””

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_id chư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

Phần tiêu đề “Quyết định kiến trúc: Contact = master, HR = satellite”
# Quyết định Giá trị
D3 “Thuộc về HR” được biểu diễn bằng gì? 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

Cả 2 đều gọi chung createPerson():

backend/extensions/hr/src/endpoints/services/people.service.ts:55-72
export async function createPerson(ctx: AppContext, accountability: any, input: CreatePersonInput): Promise<any> {
const svc = await createItemsService(ctx, 'hr_people', accountability);
const body: Record<string, any> = { ...input };
// 1. Guarantee a master contact
let contactId = body.contact_id ? Number(body.contact_id) : null;
if (contactId) {
const exists = await ctx.database('c_contacts').where('id', contactId).first();
if (!exists) throw new Error(`Contact ${contactId} not found`);
} else {
contactId = await findContactByEmail(ctx, body.email);
if (!contactId) contactId = await createContact(ctx, accountability, body);
}
body.contact_id = contactId;
// 2. Idempotency — one satellite per contact (double-submit / re-entry safe)
const existing = await findHrPersonByContact(ctx, accountability, contactId);
if (existing) return existing;
// ... backfill identity cache từ contact, sinh employee_id nếu vào stage nhân viên, tạo row ...
}

createFromContact (controller) chỉ là một lớp mỏng ép contact_id bắt buộc rồi gọi lại đúng createPerson:

backend/extensions/hr/src/endpoints/controllers/people.controller.ts:87-97
export function createFromContact(ctx: AppContext) {
return async (req: any, res: any) => {
const contactId = Number(req.body?.contact_id);
if (!contactId) {
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.

Đọc ngược: GET /people/by-contact/:contactId

Phần tiêu đề “Đọc ngược: GET /people/by-contact/:contactId”

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:

backend/extensions/hr/src/endpoints/controllers/people.controller.ts:100-112
export function getByContact(ctx: AppContext) {
return async (req: any, res: any) => {
const contactId = Number(req.params.contactId);
const found = await findHrPersonByContact(ctx, req.accountability, contactId);
if (!found) return res.send({ data: null });
const svc = await createItemsService(ctx, 'hr_people', req.accountability);
const result = await svc.readOne(found.id, {
fields: ['*', 'department_id.*', 'employment_type_id.*'],
});
return res.send({ data: result });
};
}

Đăng ký tab: string name, không resolveComponent()

Phần tiêu đề “Đăng ký tab: string name, không resolveComponent()”

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 đủ)
export default defineNuxtPlugin(() => {
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):

app/components/hr/contact/tab.vue:249-260 — loadData()
async function loadData() {
if (!props.contact?.id) return
pending.value = true
try {
employee.value = await api.getPersonByContact(props.contact.id)
// employee.value === null → empty state (2 nút Add as Talent / Add as Employee)
// employee.value !== null → filled state (contract/compensation/insurance/leave summary)
}
finally {
pending.value = false
}
}

Hai nút ở empty state đều gọi lại convertFromContact (đi qua createFromContact ở trên) và bị gate theo permission tương ứng:

app/components/hr/contact/tab.vue:232-234
const canAddTalent = computed(() => can('hr', 'recruitment.write'))
const canAddEmployee = computed(() => can('hr', 'create'))
const canCreate = computed(() => canAddTalent.value || canAddEmployee.value)

Contacts — passive host

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.