Endpoints, routes & guards
withAccess — mọi route đều qua guard này
Phần tiêu đề “withAccess — mọi route đều qua guard này”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:
function guard(ctx: AppContext, action: string, handler: (ctx: AppContext) => any) { return withAccess(ctx, 'helpdesk', action, handler(ctx));}7 action, không phải chỉ CRUD
Phần tiêu đề “7 action, không phải chỉ CRUD”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) |
validateAppAccess — resolve policy, cache 60s
Phần tiêu đề “validateAppAccess — resolve policy, cache 60s”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:
accountability.admin→ cho qua ngay (superadmin ODP).- Không có
accountability.user→ 403 luôn (route đòi hỏi đăng nhập, không có anonymous access). - Resolve
odp_access(user + role) → danh sáchpolicyid. - 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). - Còn lại: tra
odp_app_permissionstheo(policy, module, action). Action*trong DB khớp mọi action (hasMatchchấp nhậnr.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 === '*'), );}clampListLimit — chặn pull cả bảng
Phần tiêu đề “clampListLimit — chặn pull cả bảng”crud.factory.ts định nghĩa một helper dùng ở mọi endpoint list trong module:
// 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:
limitkhông hợp lệ / không truyền (NaN,0, âm khác-1) → dùngdef(hoặcundefinednếudef === 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.limithợ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ả:
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+).
Bảng route chính
Phần tiêu đề “Bảng route chính”| 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 |
Field metadata: allowlist, không phải remap
Phần tiêu đề “Field metadata: allowlist, không phải remap”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:
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.
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Cấu trúc & vòng đời — bundle, AppContext, build
- Hooks (filter/action) — logic chạy sau khi route ghi xong
- Master/Satellite & Trust model — vì sao guard này là ranh giới quyền duy nhất
- Data model — collection đứng sau mỗi route