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

Kiến trúc tổng thể

┌─────────────────────────────────────────────────────────────┐
│ Admin console (Nuxt 4, NuxtUI v4) │
│ admin/packages/hr ← Nuxt layer │
│ app/ pages · components · composables (~17 file) │
│ ▲ │
│ │ $fetch (cookie-auth) │
│ server/api/hr/** ← BFF: assertPermission + proxy │
└─────────┼─────────────────────────────────────────────────────┘
│ @odp/sdk (customEndpoint, userApi.request)
┌─────────────────────────────────────────────────────────────┐
│ ODP API server (@odp/api, Fastify + Knex) │
│ backend/extensions/hr ← bundle extension (hook + endpoint) │
│ endpoints/ routes.ts · controllers/ (22 file) · services/ (11 file) │
│ hooks/ filter/action lifecycle (không có cron) │
│ ▲ │
│ │ ItemsService / getSchema / database │
│ ▼ │
│ ODP core: permissions · activity · events · Knex · storage │
└─────────────────────────────────────────────────────────────┘
▼ Postgres/MySQL/SQLite (hr_* collections + c_contacts + odp_*)

1 · UI (Vue/Nuxt)

Pages + components + composables trong Nuxt layer admin/packages/hr. Không gọi thẳng backend — mọi request đi qua BFF cùng origin.

2 · BFF

server/api/hr/** proxy sang /hr/* của backend, đính token từ cookie, chặn quyền bằng assertPermission.

3 · Extension

endpoints/routes.ts đăng ký toàn bộ route qua 22 controller + 11 service. hooks/index.ts xử lý cascade delete, leave balance, document-gen, policy recalc, provisioning — không có cron/schedule (khác Helpdesk).

4 · ODP core

ItemsService (permission + activity + event), Knex, odp_access / odp_policies / odp_app_permissions cho app-permission model.

  • Thư mụcbackend/extensions/hr/
    • package.json khai báo odp-extension.permissions (module hr + 11 action) ngay trong manifest
    • Thư mụcsrc/
      • Thư mụcendpoints/
        • index.ts defineEndpoint → registerRoutes
        • routes.ts mọi route + guard/guardAny/guardStage
        • context.ts AppContext + createItemsService (ép admin:true)
        • middleware/require-access.ts withAccess, withAnyAccess, withStageAccess
        • Thư mụccontrollers/ people, departments, applications, interviews, transitions, notes, contracts, compensations, insurances, leave, holidays, documents, templates, triggers, assets, policies, providers, provisioning, offboarding, settings, settings-types, meta
        • Thư mụcservices/
          • people.service.ts choke point contact-first: createPerson, createFromContact
          • scope.ts getLeaveScope, getManagedDepartmentIds (Dept-Manager row-scoping)
          • app-access.ts hasAppAccess/validateAppAccess (cache 60s theo policy)
          • cascade-delete.ts cascadeDeletePerson (dọn 11 bảng con khi xoá hr_people)
          • leave-calculator.ts deductBalance/restoreBalance
          • policy-engine.ts recalculateAllEmployeeBalances/recalculatePersonBalances
          • provisioning-executor.ts executeProvision/executeDeprovision
          • document-generator.ts, template-render.ts, id-generator.ts, notify.ts
      • hooks/index.ts filter (cascade delete) + action (leave/document/policy/provisioning) — không có cron
  • Thư mụcadmin/packages/hr/
    • Thư mụcapp/ pages · components · composables · config · plugins
    • server/api/hr/** BFF routes
    • Thư mụci18n/ en.json, vi.json
    • PERMISSIONS.md action matrix theo role

Đọc danh sách employees (admin mở /hr/employees):

UI page employees/index.vue → composable use-people.ts
→ $fetch GET /api/hr/people?stage=... (BFF)
→ assertPermission({module:'hr', action:'read'})
→ userApi.request(customEndpoint(GET /hr/people)) (SDK)
→ withAccess(ctx,'hr','read', PeopleController.list)
→ createItemsService(ctx,'hr_people', accountability) → readByQuery

Tạo Talent/Employee từ Contact detail (choke point contact-first — điểm khác biệt lớn nhất với Helpdesk):

HR tab (đăng ký vào contacts.detail.tabs, luôn hiện) → GET /api/hr/people/by-contact/:contactId
→ 404/không có → empty state: nút [Add as Talent] / [Add as Employee]
→ click → POST /api/hr/people/from-contact { contact_id, stage } (BFF)
→ assertPermission + customEndpoint proxy
→ guardStage(ctx,'create', stage=body.stage, PeopleController.createFromContact)
→ people.service.createPerson(ctx, accountability, { contact_id, stage })
1. contact_id đã có sẵn → chỉ verify tồn tại (không tạo mới contact)
2. idempotency: findHrPersonByContact — có rồi thì trả về nguyên, KHÔNG tạo satellite thứ 2
3. backfill cache identity (first_name/last_name/email/phone/display_name) từ contact NẾU thiếu
4. sinh employee_id nếu stage thuộc probation/active
5. best-effort auto-link odp_users theo email trùng duy nhất
→ tab reload → filled state

Master/Satellite — phụ thuộc cứng vào Contacts

hr_people.contact_id là FK unique tới c_contacts — 1 người = 1 contact. Toàn bộ logic tạo/dedup nằm trong một service: backend/extensions/hr/src/endpoints/services/people.service.ts (createPerson, dùng chung bởi cả POST /people — dedup theo email — và POST /people/from-contact — nhận thẳng contact_id từ HR tab). Identity (first_name/email/…) được cache một lần lúc tạo, không đồng bộ nền liên tục.

Stage-aware permission — khác Helpdesk

withStageAccess (middleware/require-access.ts) đổi action cần kiểm tra theo giá trị stage của request: 3 route dùng chung (POST /people, PATCH/DELETE /people/:id, POST /people/:id/transition) đòi recruitment.write nếu stage đích/hiện tại thuộc talent/interviewing/offer, ngược lại đòi action nhân viên bình thường (create/update/delete). Helpdesk không có khái niệm này — action ở Helpdesk cố định theo route, không phụ thuộc dữ liệu request.

Row-scoping một phần cho Dept-Manager

Khác Helpdesk v1 (không có row-scoping ở bất kỳ đâu), HR một lớp row-scoping thật cho riêng leave: services/scope.tsgetLeaveScope chỉ trả mode: 'dept' khi accountability có leave.approve.dept (không có admin/leave.approve toàn cục) — resolve qua hr_people.user_id → hr_departments.head_person_id. Phạm vi row-scoping này chỉ áp dụng cho GET /leave/requests, không áp dụng cho GET /people hay các collection khác.

ItemsService — không nhất quán giữa các controller

context.ts#createItemsService ép admin:true khi có accountability — dùng ở hầu hết controller (people.controller.ts dùng xuyên suốt). Nhưng triggers.controller.ts lại khởi tạo new ItemsService(...) trực tiếp với req.accountability thật (không ép admin) — permission field/row-level của ODP thực sự áp dụng ở đúng 1 route này. Đây là một điểm không nhất quán trong code, không phải quyết định có tài liệu.

  • Extension load một lần lúc boot; “hot reload” = restart cả process — giống mọi bundle extension ODP khác. Sửa backend → rebuild dist → restart server.
  • BFF là ranh giới bắt buộc. UI không bao giờ gọi thẳng backend. Thêm route backend thì phải thêm route BFF tương ứng dưới admin/packages/hr/server/api/hr/.
  • HR không tự đứng một mình. Cài HR trên một ODP instance chưa có Contacts sẽ lỗi lúc tạo relation hr_people.contact_id → c_contacts trong Setup Wizard.
  • Không có cron/schedule trong HR — khác Helpdesk (SLA/snooze/IMAP poll). Mọi side-effect (leave balance, document-gen, provisioning) chạy đồng bộ hoặc setImmediate trong hook, kích hoạt bởi hành động người dùng, không có tiến trình nền định kỳ.