Data model
Toàn bộ collection
Phần tiêu đề “Toàn bộ collection”Tất cả PK là integer auto-increment. Mọi FK trỏ c_contacts.id cũng là integer. Thứ tự bảng dưới đây khớp sort trong schema — chính là thứ tự tạo khi Setup Wizard chạy, vì các bảng sau phụ thuộc FK vào bảng trước.
| # | Collection | Loại | Mô tả |
|---|---|---|---|
| 1 | helpdesk |
label (không có bảng) | Thư mục gom nhóm trong Data Studio |
| 2 | hd_working_hours |
table | Lịch làm việc đặt tên (timezone, schedule JSON theo ngày, holidays JSON) |
| 3 | hd_labels |
table | Nhãn hội thoại (tên, màu) |
| 4 | hd_teams |
table | Nhóm agent |
| 5 | hd_sla_policies |
table | Mục tiêu SLA (phút first/next/resolution, business_hours_only, applies_to JSON) |
| 6 | hd_settings |
singleton | Mặc định toàn module (auto-close, branding, demo data, permission cờ agent) |
| 7 | hd_connections |
table | Credential kênh dùng chung (Gmail/IMAP/SMTP/WhatsApp) — secret luôn mask khi đọc |
| 8 | hd_agents |
table | Satellite của c_contacts, 1:1 qua contact_id; role, availability, capacity |
| 9 | hd_inboxes |
table | Inbox theo kênh (→ connection, agents, team, working_hours) |
| 10 | hd_templates |
table | Template theo kênh (vd WhatsApp HSM) |
| 11 | hd_canned_responses |
table | Câu trả lời mẫu có {{variables}} |
| 12 | hd_conversations |
table | Bảng trung tâm (→ inbox, agent, team, sla_policy, c_contacts) |
| 13 | hd_messages |
table | Message trong thread + private note + activity log (→ conversation, agent, contact, template, self) |
| 14 | hd_conversation_labels |
junction | M2M conversation ↔ label |
| 15 | hd_inbox_agents |
junction | M2M inbox ↔ agent |
| 16 | hd_team_agents |
junction | M2M team ↔ agent |
| 17 | hd_message_attachments |
table | Đính kèm (→ message, odp_files) |
| 18 | hd_automation_rules |
table | Rule (trigger, conditions JSON, actions JSON, is_active, run_count) |
| 19 | hd_saved_views |
table | Bộ lọc conversation đã lưu (owner_id → agent, is_shared, filters JSON) |
Collection dùng chung tham chiếu tới nhưng không thuộc helpdesk:
c_contacts— master identity (requester + agent contact)odp_files— file đính kèmodp_users/odp_access/odp_policies/odp_app_permissions— mô hình auth + quyền (xem Endpoints, routes & guards)c_contact_intakes— nguồn intake liên kết quahd_conversations.intake_id
Cấu trúc thư mục schema nguồn
Phần tiêu đề “Cấu trúc thư mục schema nguồn”Thư mụcadmin/packages/helpdesk/
- app/data/helpdesk-schema.ts ModuleSchema — collections + fields + relations + seed, chạy qua Setup Wizard
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 collection theo đúng sort (2→19): bảng lookup độc lập trước (hd_working_hours, hd_labels, hd_teams, hd_sla_policies, hd_settings, hd_connections), rồi hd_agents (phụ thuộc c_contacts), rồi hd_inboxes (phụ thuộc connections/agents/teams/working_hours), rồi hd_conversations (phụ thuộc c_contacts + mọi bảng trước), rồi hd_messages, rồi 3 junction, rồi attachments/automation/saved-views.
Bảng trung tâm: hd_conversations
Phần tiêu đề “Bảng trung tâm: hd_conversations”Các field đáng chú ý ngoài CRUD cơ bản:
| Field | Type | Ghi chú |
|---|---|---|
contact_id |
integer, required | M2O → c_contacts — requester, xem Master/Satellite |
intake_id |
integer | M2O → c_contact_intakes |
status |
string | open / pending / snoozed / resolved — không có spam (report spam = set resolved) |
sla_status |
string, readonly | on_track / at_risk / breaching / breached / met — do cron ghi, không cho form sửa tay |
snooze_until |
timestamp | Cron autoReopenSnoozed đọc field này mỗi phút |
last_activity_at |
timestamp, readonly | Bump bởi raw Knex trong hook hd_messages.items.create, không qua form |
waiting_since |
timestamp, readonly | Mốc tính next_response_within_min |
mentioned_agent_ids |
json | Mảng id, hidden: true — match bằng _contains substring trong query counts |
label_ids |
alias, special: ["m2m"] |
Không phải cột thật — join qua hd_conversation_labels |
custom_attrs, channel_meta |
json, hidden | Payload mở rộng theo kênh |
hd_messages — 4 direction, không chỉ incoming/outgoing
Phần tiêu đề “hd_messages — 4 direction, không chỉ incoming/outgoing”direction có 4 giá trị: incoming, outgoing, private_note, activity. activity dùng cho log hệ thống (vd “Conversation created” — xem conversation.service.ts), không phải nội dung gửi cho khách. sender_type (contact/agent/system) quyết định sender_contact_id hay sender_agent_id được set.
Field threading/dedup email (nhóm P0 inbound):
| Field | Ghi chú |
|---|---|
external_message_id |
RFC822 Message-ID, is_unique: true — khoá dedup khi poll IMAP lặp lại |
email_in_reply_to |
Header In-Reply-To |
email_references |
Header References[], JSON |
email_headers |
Header thô giữ lại để debug threading, hidden: true |
in_reply_to_id |
Self-M2O sang hd_messages khác — threading nội bộ |
hd_agents — satellite, không phải identity
Phần tiêu đề “hd_agents — satellite, không phải identity”hd_agents có cột name/email/avatar trong schema, nhưng đây là cache ghi-một-lần cho response, không phải nguồn sự thật — mọi lần list/get, m2m-hydrator.ts ghi đè các field này từ c_contacts (display_name/primary_email/avatar) trước khi trả về client. contact_id là FK bắt buộc (required: true, is_nullable: false), template dropdown {{display_name}}.
Quan hệ tới c_contacts / odp_files
Phần tiêu đề “Quan hệ tới c_contacts / odp_files”| Từ | Field | Tới | one_deselect_action |
|---|---|---|---|
hd_agents |
contact_id |
c_contacts |
nullify |
hd_agents |
avatar |
odp_files |
nullify |
hd_conversations |
contact_id |
c_contacts |
nullify (cross-module, effectively required ở tầng ứng dụng) |
hd_conversations |
intake_id |
c_contact_intakes |
nullify |
hd_messages |
sender_contact_id |
c_contacts |
nullify |
hd_message_attachments |
file |
odp_files |
nullify |
hd_settings |
logo |
(uuid, file-image) | — |
hd_messages.conversation_id → hd_conversations và hd_message_attachments.message_id → hd_messages là hai quan hệ duy nhất có one_deselect_action: "delete" (cascade thật ở tầng schema) — khớp với việc cascadeDeleteConversation() xoá message/attachment tường minh trước khi xoá conversation (xem Hooks).
3 junction M2M — hydrate thủ công, không để ItemsService tự sync
Phần tiêu đề “3 junction M2M — hydrate thủ công, không để ItemsService tự sync”| Junction | Nối | Field alias tương ứng |
|---|---|---|
hd_conversation_labels |
hd_conversations ↔ hd_labels |
hd_conversations.label_ids |
hd_inbox_agents |
hd_inboxes ↔ hd_agents |
hd_inboxes.agent_ids, hd_agents.inbox_ids |
hd_team_agents |
hd_teams ↔ hd_agents |
hd_teams.agent_ids, hd_agents.team_ids |
Cả 3 field alias (label_ids, agent_ids, inbox_ids, team_ids) đều type: "alias", special: ["m2m"] — không phải cột thật trên bảng chính. m2m-hydrator.ts build chúng bằng một query whereIn cho mỗi junction, gom vào Map, rồi gán ngược lên từng row parent — không phải N+1 pluck từng row:
export async function hydrateInboxes(ctx: AppContext, inboxes: any[]) { if (!inboxes.length) return; const ids = inboxes.map((i: any) => i.id);
const agentRows = await ctx.database('hd_inbox_agents') .whereIn('hd_inboxes_id', ids) .select('hd_inboxes_id', 'hd_agents_id');
const agentsByInbox = new Map<number, number[]>(); for (const row of agentRows) { const arr = agentsByInbox.get(row.hd_inboxes_id) || []; arr.push(row.hd_agents_id); agentsByInbox.set(row.hd_inboxes_id, arr); }
for (const i of inboxes) { i.agent_ids = agentsByInbox.get(i.id) || []; }}Với hd_conversation_labels cụ thể, việc ghi cũng đi vòng raw Knex thay vì để ItemsService.updateOne tự M2M-sync — comment trong conversations.controller.ts giải thích lý do đây không phải chọn lựa tuỳ ý:
// label_ids is the hd_conversation_labels junction, managed manually// everywhere in this module (hydrator, automation, security-alert). Handle// it explicitly here too: letting ItemsService M2M-sync a raw id array is// non-idempotent (re-sending an existing label duplicated the junction row,// then further writes 500'd with "referenced record does not exist").Giải pháp: xoá hết rồi insert lại tập đích đã de-dupe (syncConversationLabels) — idempotent tuyệt đối, và tự sửa luôn dữ liệu trùng lặp cũ nếu có:
async function syncConversationLabels(ctx: AppContext, conversationId: number, labelIds: any[]): Promise<void> { const db = ctx.database; const desired = [...new Set(labelIds.map((v) => Number(v)).filter((n) => Number.isFinite(n)))];
await db('hd_conversation_labels').where('hd_conversations_id', conversationId).del(); if (desired.length) { await db('hd_conversation_labels') .insert(desired.map((lid) => ({ hd_conversations_id: conversationId, hd_labels_id: lid }))); }}Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Master/Satellite & Trust model — vì sao identity không nằm trong bảng nào ở trên
- Endpoints, routes & guards — allowlist field metadata, route CRUD
- Hooks (filter/action) — mã hoá secret
hd_connections/hd_inboxes, cascade delete - Schedules (cron) — cron đọc/ghi
sla_status,snooze_until