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.
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:
hd_conversations.contact_id trỏ thẳng vào c_contacts, không có bảng trung gian.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.
// 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' },};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).
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:
// 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:
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.
withAccess + validateAppAccess + cache 60shd_* và quan hệ tới c_contacts