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

Hooks (filter/action)

filter — trước khi ghi DB

Chạy tuần tự, nhận payload của handler trước, có thể sửa hoặc throw để huỷ toàn bộ operation. Dùng cho mã hoá, gán default, validate.

action — sau khi ghi DB

Chạy song song, lỗi bị nuốt và log, không bao giờ ảnh hưởng response đã trả về client. Dùng cho side-effect: bump timestamp, chạy automation, cascade cleanup.

Mã hoá secret tại filter — trước khi chạm DB

Phần tiêu đề “Mã hoá secret tại filter — trước khi chạm DB”

hd_connections lưu OAuth token / IMAP password / SMTP password / Twilio token. Extension không dựa vào cột DB đã mã hoá sẵn — nó tự mã hoá trong filter, ngay trước khi payload chạm DB:

backend/extensions/helpdesk/src/hooks/index.ts
const SECRET = String(env?.SECRET ?? '');
const encryptConn = (payload: any) => {
if (!payload || !SECRET) return payload;
return encryptFields(payload, ENCRYPTED_CONNECTION_FIELDS, SECRET);
};
// On create, an IMAP/SMTP connection with IMAP creds is inbound-pollable by
// default (so a wizard-created IMAP inbox works without an extra form field).
// Users can clear inbound_provider to disable polling.
const withInboundDefault = (payload: any) => {
if (payload && !payload.inbound_provider && (payload.provider === 'imap_smtp' || payload.imap_host)) {
payload.inbound_provider = 'imap';
payload.auth_type = payload.auth_type ?? 'password';
}
return payload;
};
filter('hd_connections.items.create', (payload: any) => withInboundDefault(encryptConn(payload)));
filter('hd_connections.items.update', (payload: any) => encryptConn(payload));

encryptFields/isEncrypted (trong services/inbound/crypto.ts) idempotent — gọi lại trên giá trị đã mã hoá sẽ bỏ qua, tránh double-encrypt khi update chỉ đổi field khác. Đọc-lại (mask hiển thị ***) là việc của normalizer.ts, không phải hook này — xem Master/Satellite & Trust model.

Tương tự cho SMTP password custom theo từng inbox (hd_inboxes.outbound_override.smtp_password):

const encryptInboxOutbound = (payload: any) => {
if (!payload || !SECRET || payload.outbound_override == null) return payload;
let ob = payload.outbound_override;
if (typeof ob === 'string') { try { ob = JSON.parse(ob); } catch { return payload; } }
if (ob && typeof ob === 'object' && ob.smtp_password && !isEncrypted(ob.smtp_password)) {
payload.outbound_override = { ...ob, smtp_password: encrypt(String(ob.smtp_password), SECRET) };
}
return payload;
};
filter('hd_inboxes.items.create', (payload: any) => encryptInboxOutbound(payload));
filter('hd_inboxes.items.update', (payload: any) => encryptInboxOutbound(payload));

Khi một message mới được tạo (hd_messages.items.create), hook bump last_activity_at của conversation cha bằng raw Knex (không phải ItemsService) — có comment giải thích rõ vì sao:

// Bump the conversation's last_activity_at. Intentionally raw Knex: this writes a
// denormalized timestamp mirror, not a logical edit — routing through
// ItemsService would emit a spurious hd_conversations.update event and could
// recurse through other hooks.
async function bumpLastActivity(database: Knex, conversationId: number) {
if (!conversationId) return;
await database('hd_conversations')
.where('id', conversationId)
.update({ last_activity_at: new Date().toISOString() });
}

Sau đó, chỉ message chiều incoming mới trigger automation message_received (tin nhắn đi/hệ thống không được phép lặp lại chính nó):

action('hd_messages.items.create', async (meta: any) => {
if (meta.collection && meta.collection !== 'hd_messages') return;
const key = meta.key ?? (Array.isArray(meta.keys) ? meta.keys[0] : undefined);
if (key == null) return;
const message = await database('hd_messages').where('id', Number(key)).first();
if (!message?.conversation_id) return;
await bumpLastActivity(database, Number(message.conversation_id));
// NOTE: outbound email delivery is triggered explicitly by the message
// creators (controller / automation / conversation.service) AFTER any
// attachments are linked — not here — so the create-hook can't race the
// attachment link and send a reply before its files are attached.
if (message.direction === 'incoming') {
await runGuarded(ctx, 'message_received', Number(message.conversation_id), {
message: { content: message.content, direction: message.direction },
});
}
});

hd_conversations.items.create → trigger conversation_created; hd_conversations.items.update chỉ trigger status_changed khi status thực sự nằm trong payload đã đổi (không phản ứng với mọi field touch):

action('hd_conversations.items.update', async (meta: any) => {
if (meta.collection && meta.collection !== 'hd_conversations') return;
const payload = meta.payload ?? {};
if (!Object.prototype.hasOwnProperty.call(payload, 'status')) return;
const keys: number[] = (meta.keys ?? (meta.key != null ? [meta.key] : [])).map(Number);
for (const key of keys) {
await runGuarded(ctx, 'status_changed', key);
}
});

Automation action (assign, label, set_status, …) tự ghi lại vào hd_conversations qua ItemsService — điều này phát ra event hd_conversations.items.update mới, có thể khớp lại chính hook status_changed ở trên và trigger automation lặp vô hạn. Guard nằm trong services/automation/engine.ts (không phải trong hooks/index.ts) để chia sẻ được giữa hook và cron SLA:

backend/extensions/helpdesk/src/endpoints/services/automation/engine.ts
// Re-entrancy guard. ... We record the conversation ids currently inside an
// automation run and skip automation for events whose conversation is being
// processed. Shared across all callers (hooks + SLA cron) so a guarded run
// from one entry point also blocks the other from re-entering the same
// conversation.
const automationInFlight = new Set<number>();
export async function runGuarded(
ctx: AppContext,
trigger: AutomationTrigger,
conversationId: number,
event: EventContext = {},
): Promise<void> {
if (!conversationId || automationInFlight.has(conversationId)) return;
automationInFlight.add(conversationId);
try {
await runAutomationForEvent(ctx, SYSTEM_ACCOUNTABILITY, trigger, conversationId, event);
} catch (err: any) {
ctx.log.error({ err: err?.message, trigger, conversationId }, 'helpdesk automation run failed');
} finally {
automationInFlight.delete(conversationId);
}
}

sla.ts (recomputeSlaStatus) cũng gọi runGuarded(ctx, 'sla_warning', conversationId) khi một conversation crossing ngưỡng SLA, guard dùng chung nghĩa là: nếu automation đang chạy cho conversation #123 từ hook status_changed, cron SLA tick trong cùng giây đó cũng bị chặn re-enter conversation #123 — không phải hai guard riêng biệt có thể cùng cho qua.

Cascade delete: filter capture keys, action dọn dẹp

Phần tiêu đề “Cascade delete: filter capture keys, action dọn dẹp”

hd_conversations.items.delete cần biết id trước khi row bị xoá (payload delete chỉ là mảng id) để có thể dọn hd_messages/hd_message_attachments liên quan sau đó. Cách giải: một filter chỉ để “chụp” key, một action chạy dọn dẹp thật:

const pendingDeletes = new Map<string, number[]>();
filter('hd_conversations.items.delete', async (payload: any, meta: any) => {
if (meta.collection && meta.collection !== 'hd_conversations') return payload;
const keys = (payload as any[]).map(Number);
pendingDeletes.set(keys.join(','), keys);
return payload;
});
action('hd_conversations.items.delete', async (meta: any) => {
if (meta.collection && meta.collection !== 'hd_conversations') return;
const keys = (meta.keys ?? meta.payload ?? []).map(Number);
const cacheKey = keys.join(',');
pendingDeletes.delete(cacheKey);
if (!keys.length) return;
const messageIds = await database('hd_messages').whereIn('conversation_id', keys).pluck('id');
if (messageIds.length) {
await database('hd_message_attachments').whereIn('message_id', messageIds).del();
await database('hd_messages').whereIn('id', messageIds).del();
}
});

Comment trong code nói rõ vai trò: đây là safety net, không phải đường xoá chính. Đường chính là cascadeDeleteConversation() trong conversation.service.ts, gọi tường minh từ ConversationsController.remove trước deleteOne. Hook này chỉ bắt các đường xoá “tắt” (ItemsService trực tiếp, admin thao tác thẳng) không đi qua controller.

Event Loại Việc làm
hd_connections.items.create filter Mã hoá secret + set inbound_provider mặc định nếu là IMAP/SMTP
hd_connections.items.update filter Mã hoá secret (idempotent)
hd_inboxes.items.create/.update filter Mã hoá outbound_override.smtp_password
hd_messages.items.create action Bump last_activity_at (raw Knex) + runGuarded('message_received') nếu direction === 'incoming'
hd_conversations.items.create action runGuarded('conversation_created')
hd_conversations.items.update action runGuarded('status_changed') nếu status nằm trong payload đổi
hd_conversations.items.delete filter + action Capture key rồi cascade xoá messages + attachments (safety net)
init('app.after') init Seed template thông báo bảo mật (ensureSecurityTemplate), idempotent

Tất cả action chạy với SYSTEM_ACCOUNTABILITY = { admin: true, role: null, user: null } — cùng triết lý “server sở hữu automation” như cron (xem Schedules): việc chạy automation/SLA không phụ thuộc user nào đang đăng nhập hay tab nào đang mở.