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.
┌─────────────────────────────────────────────────────────────┐│ 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.
odp-extension.permissions (module hr + 11 action) ngay trong manifestĐọ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) → readByQueryTạ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 stateMaster/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 có một
lớp row-scoping thật cho riêng leave: services/scope.ts →
getLeaveScope 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.
dist → restart server.admin/packages/hr/server/api/hr/.hr_people.contact_id → c_contacts trong Setup Wizard.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ỳ.hr_* và quan hệ tới c_contacts