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

Data model

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èm
  • odp_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 qua hd_conversations.intake_id
  • 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.

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 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}}.

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_conversationshd_message_attachments.message_id → hd_messages là hai quan hệ duy nhấtone_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_conversationshd_labels hd_conversations.label_ids
hd_inbox_agents hd_inboxeshd_agents hd_inboxes.agent_ids, hd_agents.inbox_ids
hd_team_agents hd_teamshd_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:

backend/extensions/helpdesk/src/endpoints/services/m2m-hydrator.ts
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ỳ ý:

backend/extensions/helpdesk/src/endpoints/controllers/conversations.controller.ts
// 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 })));
}
}