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

Master/Satellite & Trust model

Trước khi có helpdesk, c_contacts đã là nơi lưu danh tính dùng chung cho Contacts + HR. Nếu helpdesk tự thêm cột name/email trên hd_conversations hay hd_agents, hệ thống sẽ có 2-3 bản sao của cùng một người, lệch nhau dần theo thời gian (khách đổi email ở Contacts nhưng helpdesk không biết).

Giải pháp: doctrine Master/Satellite.

Master — c_contacts

Nguồn sự thật duy nhất cho display_name, primary_email, primary_phone, avatar. Dùng chung giữa Contacts, HR, Helpdesk.

Satellite — hd_agents, hd_conversations

Không lưu identity. Chỉ giữ khoá ngoại contact_id trỏ về c_contacts. Cần tên/email → JOIN sang contact.

Hai loại “người” trong helpdesk đều đi qua master này:

  • Requester — khách hàng gửi email vào. hd_conversations.contact_id trỏ thẳng vào c_contacts, không có bảng trung gian.
  • Agent — nhân viên hỗ trợ. hd_agents là bảng satellite 1:1 với c_contacts qua hd_agents.contact_id (unique theo logic, không phải theo DB constraint — xem phần idempotency bên dưới).

Toàn bộ logic tạo/tìm contact nằm trong một file: backend/extensions/helpdesk/src/endpoints/services/contact-choke-point.ts. Không có chỗ nào khác trong helpdesk được phép gọi ItemsService('c_contacts') để tạo mới.

backend/extensions/helpdesk/src/endpoints/services/contact-choke-point.ts
// The SINGLE creation door for helpdesk identity. Mirrors HR `createPerson`:
// identity (name/email/phone) lives ONLY on `c_contacts` (the master party);
// helpdesk satellites (`hd_agents`, `hd_conversations`) key by `contact_id`.
export async function findContactByEmail(
ctx: AppContext,
accountability: any,
email: string,
): Promise<any | null> {
const trimmed = email?.trim();
if (!trimmed) return null;
const svc = await createItemsService(ctx, 'c_contacts', accountability);
const rows = await svc.readByQuery({
filter: { primary_email: { _eq: trimmed } },
fields: ['*'],
limit: 1,
});
return rows?.[0] ?? null;
}

c_contacts không có cột source riêng — provenance được ghi vào metadata JSON ({ source: 'helpdesk' }) thay vì bịa thêm cột:

const payload: Record<string, any> = {
type: 'person',
display_name,
primary_email: input.email?.trim() || '',
primary_phone: input.phone?.trim() || '',
status: 'contacts:active',
// c_contacts has no `source` field — record provenance in metadata json.
metadata: { source: 'helpdesk' },
};

ensureContactForRequester — find-or-create theo email

Phần tiêu đề “ensureContactForRequester — find-or-create theo email”

Dùng khi tạo conversation mới (từ UI hoặc từ pipeline inbound email). Dedup key là primary_email:

export async function ensureContactForRequester(
ctx: AppContext,
accountability: any,
input: RequesterInput,
): Promise<number> {
if (input.contact_id != null && input.contact_id !== '') {
const id = Number(input.contact_id);
const svc = await createItemsService(ctx, 'c_contacts', accountability);
const existing = await svc.readOne(id, { fields: ['id'] });
return Number(existing.id);
}
const found = input.email ? await findContactByEmail(ctx, accountability, input.email) : null;
const contact = found ?? await createContact(ctx, accountability, {
name: input.name,
email: input.email,
phone: input.phone,
});
return Number(contact.id);
}

Được gọi từ conversation.service.ts (createConversationWithFirstMessage) và từ pipeline inbound email (mỗi email mới từ một địa chỉ chưa từng thấy → tạo contact mới; email từ địa chỉ đã biết → tái sử dụng).

ensureContactForAgent — resolve contact rồi tạo satellite idempotent

Phần tiêu đề “ensureContactForAgent — resolve contact rồi tạo satellite idempotent”
export async function ensureContactForAgent(
ctx: AppContext,
accountability: any,
input: AgentInput,
): Promise<any> {
let contactId: number;
// ... resolve/create contact (giống ensureContactForRequester) ...
// Idempotent satellite create by contact_id (1:1).
const existingAgent = await findAgentByContact(ctx, accountability, contactId);
if (existingAgent) return existingAgent;
const agentSvc = await createItemsService(ctx, 'hd_agents', accountability);
const payload: Record<string, any> = {
contact_id: contactId,
role: input.role || 'agent',
};
if (input.availability !== undefined) payload.availability = input.availability;
if (input.capacity !== undefined) payload.capacity = input.capacity;
const agentId = await agentSvc.createOne(payload);
return agentSvc.readOne(agentId, { fields: ['*'] });
}

Idempotency ở đây quan trọng vì đây chính là logic đằng sau GET /agents/me — mỗi lần user mở helpdesk lần đầu, client gọi /agents/me, backend resolve contact theo user hiện tại rồi ensureContactForAgent. Gọi hai lần liên tiếp (double-submit, tab kép, race) không tạo ra hai hd_agents row — lần gọi thứ hai tìm thấy satellite đã tồn tại theo contact_id và trả về y nguyên.

Vì satellite không lưu tên/email, mọi endpoint trả về conversation/agent phải JOIN ngược sang c_contacts trước khi trả cho client. Việc này nằm trong m2m-hydrator.ts, không phải trong normalizer.ts:

backend/extensions/helpdesk/src/endpoints/services/m2m-hydrator.ts
// A conversation keys the requester by contact_id (master/satellite: identity
// lives on c_contacts). We hydrate a `requester` object so the client can render
// the name/email/avatar directly...
c.requester = contact
? {
id: String(contact.id),
display_name: contact.display_name ?? null,
primary_email: contact.primary_email || null,
avatar: contact.avatar ?? null,
}
: null;

hydrateAgents làm tương tự cho hd_agents — copy display_name/primary_email/avatar từ contact vào a.name/a.email/a.avatar trước khi trả về (đây là cache ghi-một-lần cho response, không phải cột lưu trữ chính thức — cột name/email trên hd_agents tồn tại trong schema nhưng identity thật luôn đọc lại từ contact ở mọi lần list/get).

Điểm khởi tạo ItemsService duy nhất trong toàn extension là createItemsService() trong context.ts. Nhìn kỹ dòng ép quyền:

backend/extensions/helpdesk/src/endpoints/context.ts
export async function createItemsService(ctx: AppContext, collection: string, accountability?: any) {
const schema = await ctx.getSchema();
const effectiveAccountability = accountability
? { ...accountability, admin: true }
: null;
return new ctx.services.ItemsService(collection, {
knex: ctx.database,
accountability: effectiveAccountability,
schema,
});
}

Nghĩa là: bất kỳ accountability nào truyền vào cũng bị override admin: true trước khi tạo ItemsService. ODP’s row-level/field-level permission system (permissions per role/policy trên từng collection) hoàn toàn bị bỏ qua bên trong helpdesk — một agent với accountability bình thường vẫn đọc/ghi được mọi hàng hd_conversations, hd_messages, kể cả những gì không thuộc inbox/team của họ.

Duy nhất ở middleware withAccess(module, action) bọc quanh mỗi route (middleware/require-access.ts + services/app-access.ts — chi tiết ở Endpoints, routes & guards). Guard này trả lời được câu hỏi “user có quyền helpdesk:read không?” nhưng không trả lời được “user có được xem conversation #123 không?”.

Có: Module-level gate

withAccess(ctx, 'helpdesk', action, handler) chặn theo action (read/create/update/delete/manage-*) trước khi handler chạy.

Chưa có: Row-level scoping

Không có WHERE inbox_id IN (...) hay WHERE assignee_id = ... bắt buộc theo agent. Bất kỳ ai có helpdesk:read thấy toàn bộ conversation trong hệ thống.