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

Endpoints, routes & guards

backend/extensions/helpdesk/src/endpoints/middleware/require-access.ts
export function withAccess(ctx: AppContext, module: string, action: string, handler: RouteHandler): RouteHandler {
return async (req: any, res: any) => {
try {
await validateAppAccess(ctx.database, req.accountability, module, action);
} catch (err: any) {
return res.status(err.status ?? 403).send({
errors: [{ message: err.message ?? 'Forbidden' }],
});
}
return handler(req, res);
};
}

routes.ts định nghĩa một helper guard() ngắn gọn để không phải lặp withAccess(ctx, 'helpdesk', ...) ở mọi dòng:

backend/extensions/helpdesk/src/endpoints/routes.ts
function guard(ctx: AppContext, action: string, handler: (ctx: AppContext) => any) {
return withAccess(ctx, 'helpdesk', action, handler(ctx));
}

Action khớp với permissions.actions khai báo trong package.json (xem Cấu trúc & vòng đời):

Action Dùng cho
read Mọi GET (list/get), kể cả /agents/me, /fields/:collection, /reports
create POST tạo mới (conversations, messages, canned-responses, …)
update PATCH
delete DELETE
manage-inboxes Mutation trên hd_inboxes + hd_connections (đọc vẫn chỉ cần read)
manage-automation Mutation trên hd_automation_rules (đọc vẫn chỉ cần read)
manage-settings PATCH /settings (đọc GET /settings chỉ cần read)
backend/extensions/helpdesk/src/endpoints/services/app-access.ts
export async function validateAppAccess(
database: any,
accountability: any,
module: string,
action: string,
): Promise<void> {
if (!accountability?.user) throw forbidden();
if (accountability.admin) return;
const roles: string[] = accountability.roles
?? (accountability.role ? [accountability.role] : []);
const accessRows = await database('odp_access')
.select('policy')
.where(function (this: any) {
this.where('user', accountability.user);
if (roles.length) this.orWhereIn('role', roles);
});
const policyIds = [...new Set<string>(accessRows.map((r: any) => r.policy))];
if (policyIds.length === 0) throw forbidden();
const adminPolicy = await database('odp_policies')
.whereIn('id', policyIds)
.where('admin_access', true)
.first();
if (adminPolicy) return;
const cacheKey = getCacheKey(policyIds);
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
if (hasMatch(cached.records, module, action)) return;
throw forbidden();
}
const rows = await database('odp_app_permissions')
.whereIn('policy', policyIds)
.select('module', 'action');
cache.set(cacheKey, { records: rows, timestamp: Date.now() });
if (hasMatch(rows, module, action)) return;
throw forbidden();
}

Thứ tự kiểm tra ngắn mạch:

  1. accountability.admin → cho qua ngay (superadmin ODP).
  2. Không có accountability.user → 403 luôn (route đòi hỏi đăng nhập, không có anonymous access).
  3. Resolve odp_access (user + role) → danh sách policy id.
  4. Nếu một trong các policy có admin_access: true → cho qua (admin theo policy, không cần token admin thật).
  5. Còn lại: tra odp_app_permissions theo (policy, module, action). Action * trong DB khớp mọi action (hasMatch chấp nhận r.action === '*').

hasMatch:

function hasMatch(
records: Array<{ module: string; action: string }>,
module: string,
action: string,
): boolean {
return records.some(
(r) => r.module === module && (r.action === action || r.action === '*'),
);
}

crud.factory.ts định nghĩa một helper dùng ở mọi endpoint list trong module:

backend/extensions/helpdesk/src/endpoints/controllers/crud.factory.ts
// Clamp a list limit. `-1` is the Directus/ODP "unlimited" sentinel → cap it;
// unset/invalid → the caller's default (or leave unset when def is null).
export function clampListLimit(value: unknown, def: number | null, max: number): number | undefined {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return n === -1 ? max : def ?? undefined;
return Math.min(n, max);
}

Ba tình huống:

  • limit không hợp lệ / không truyền (NaN, 0, âm khác -1) → dùng def (hoặc undefined nếu def === null, tức “không giới hạn mặc định”).
  • limit === -1 (sentinel “unlimited” của ODP/Directus) → không cho unlimited thật, ép về max.
  • limit hợp lệ dương → Math.min(limit, max).

Mỗi controller chọn cặp (default, cap) khác nhau theo đặc thù dữ liệu:

Endpoint default cap Lý do
GET /conversations 25 100 Trang UI chính, phân trang thật
GET /messages (flat) 50 200 Xuyên mọi conversation — cap chặt hơn
GET /conversations/:id/messages null (không mặc định) 1000 Một thread hiếm khi vượt — không muốn cắt lịch sử mặc định
CRUD chung (makeCrud) 100 500 Các bảng cấu hình nhỏ (labels, teams, …)

crud.factory.ts — pattern ItemsService pass-through

Phần tiêu đề “crud.factory.ts — pattern ItemsService pass-through”

makeCrud(collection) sinh ra { list, get, create, update, remove } dùng cho các entity không cần logic đặc thù (labels, teams, canned-responses, templates, sla-policies, working-hours, saved-views):

export function makeCrud(collection: string) {
function list(ctx: AppContext) {
return async (req: any, res: any) => {
const svc = await createItemsService(ctx, collection, req.accountability);
const query = { ...req.sanitizedQuery };
query.limit = clampListLimit(query.limit, 100, 500);
const schema = await ctx.getSchema();
const metaSvc = new ctx.services.MetaService({
knex: ctx.database, schema,
accountability: req.accountability ? { ...req.accountability, admin: true } : null,
});
const [data, meta] = await Promise.all([
svc.readByQuery(query),
metaSvc.getMetaForQuery(collection, { ...query, meta: ['total_count', 'filter_count'] }),
]);
return res.send({ data, meta });
};
}
// get / create / update / remove tương tự — mỗi hàm chỉ gọi 1-2 method
// ItemsService rồi trả thẳng { data }.
return { list, get, create, update, remove };
}

Mọi list trả về { data, meta } với meta.total_count/meta.filter_count từ MetaService — client dùng cặp này để vẽ phân trang mà không cần đếm riêng.

Khi cần hơn CRUD thuần: conversations.controller.ts

Phần tiêu đề “Khi cần hơn CRUD thuần: conversations.controller.ts”

ConversationsController.list không dùng makeCrud vì cần build filter động từ nhiều query param rời rạc, resolve smart-view, và hydrate requester trước khi trả:

backend/extensions/helpdesk/src/endpoints/controllers/conversations.controller.ts
const and: any[] = [];
if (q.status) and.push({ status: { _eq: String(q.status) } });
if (q.inbox_id) and.push({ inbox_id: { _eq: Number(q.inbox_id) } });
if (q.assignee_id === 'null' || q.assignee_id === '_null') {
and.push({ assignee_id: { _null: true } });
} else if (q.assignee_id) {
and.push({ assignee_id: { _eq: Number(q.assignee_id) } });
}
if (q.label_id) {
// Resolve the M2M via the junction directly (reliable, avoids alias
// filter ambiguity): conversations carrying the label.
const convIds = await ctx.database('hd_conversation_labels')
.where('hd_labels_id', Number(q.label_id))
.pluck('hd_conversations_id');
and.push({ id: { _in: convIds.length ? convIds : [-1] } });
}

Sau khi readByQuery, kết quả đi qua hydrateConversations (gắn label_ids + requester) rồi normalizeConversation (stringify id, mask) trước khi trả — xem chi tiết ở Master/Satellite & Trust model.

GET /conversations/counts là route duy nhất dùng raw Knex aggregation thuần (không qua ItemsService) — hợp lý vì đây là COUNT ... GROUP BY, không phải CRUD trên item nào cả. Route này được đăng ký trước registerCrud(router, ctx, '/conversations', ...) để /conversations/counts không bị nuốt bởi pattern :id(\\d+).

Route Method Guard action Ghi chú
/conversations/counts GET read Aggregation thuần, đăng ký trước route :id
/conversations GET/POST/PATCH/DELETE read/create/update/delete create đi qua conversation.service.ts
/conversations/:id/messages GET/POST read/create Nested dưới conversation
/messages GET/POST/PATCH/DELETE tương ứng Flat, xuyên mọi conversation
/agents/me GET read Đăng ký trước registerCrud('/agents', ...) — nếu không, :id(\\d+) sẽ không khớp me nên thực ra không xung đột, nhưng đặt trước để rõ ý định
/agents, /labels, /teams, /canned-responses, /templates, /sla-policies, /working-hours, /saved-views CRUD read/create/update/delete Qua registerCrud + makeCrud
/inboxes/reveal-smtp POST manage-inboxes Giải mã SMTP password on-demand, đăng ký trước :id
/inboxes GET read; mutation manage-inboxes
/connections/test-imap, /connections/test-smtp POST manage-inboxes Test kết nối thật, không lưu
/connections GET read; mutation manage-inboxes
/automation-rules GET read; mutation manage-automation
/fields/:collection GET read Allowlist trong meta.controller.ts, non-allowlisted → 404
/settings GET/PATCH read/manage-settings Singleton
/reports GET read Một round-trip tổng hợp cho trang Reports

GET /fields/:collection (meta.controller.ts) chặn theo allowlist cứng, không có cơ chế remap tên ngắn → tên thật như module Contacts:

backend/extensions/helpdesk/src/endpoints/controllers/meta.controller.ts
const ALLOWED = new Set<string>([
'hd_inboxes', 'hd_agents', 'hd_teams', 'hd_sla_policies', 'hd_working_hours',
'hd_automation_rules', 'hd_canned_responses', 'hd_templates', 'hd_saved_views',
'hd_connections', 'hd_settings', 'hd_conversations', 'hd_messages',
'c_contacts',
]);
export function fields(ctx: AppContext) {
return async (req: any, res: any) => {
const { collection } = req.params;
if (!ALLOWED.has(collection)) {
return res.status(404).send({ errors: [{ message: 'Unknown collection' }] });
}
// ...
};
}

c_contacts nằm trong allowlist chỉ để phục vụ field metadata cho M2O hd_agents.contact_id (template {{display_name}} trong dropdown) — không phải để helpdesk quản lý field của contacts.