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

Frontend (Nuxt layer + BFF)

pages/hr/** + components/hr/**
│ gọi composable (không tự $fetch)
app/composables/use-hr-api.ts ← raw $fetch wrapper cho MỌI route HR
app/composables/use-people.ts, use-departments.ts, use-leave.ts, … ← entity composable dùng use-hr-api()
│ $fetch (cookie-auth, cùng origin)
server/api/hr/** ← BFF: assertPermission({module:'hr', action}) (+ stage-aware cho vài route) + customEndpoint proxy
│ @odp/sdk userApi.request()
backend/extensions/hr (ODP API)

Composable: 1 raw wrapper + nhiều entity composable

Phần tiêu đề “Composable: 1 raw wrapper + nhiều entity composable”

Không như helpdesk (14 entity store độc lập, mỗi cái tự $fetch), HR gom toàn bộ lời gọi HTTP vào một composable duy nhất — use-hr-api.ts — rồi các composable “nghiệp vụ” phía trên gọi lại nó thay vì tự $fetch:

  • Thư mụcapp/composables/
    • use-hr-api.ts raw $fetch wrapper — MỌI endpoint HR đi qua đây
    • use-people.ts state danh sách người (talents/employees derive từ stage), hire/reactivate/transition
    • use-departments.ts
    • use-leave.ts
    • use-holidays.ts
    • use-employee-detail.ts provide/inject context cho layout chi tiết nhân viên (không gọi API)
    • use-hr-settings.ts
    • use-hr-policies.ts
    • use-hr-field-registry.ts
    • use-triggers.ts
    • use-templates.ts
    • use-providers.ts
    • use-offboarding.ts
    • use-recruitment-workflow.ts

use-employee-detail.ts là composable duy nhất không gọi use-hr-api() — nó chỉ là một cặp provide/inject (EmployeeDetailContext) để layout hr/employees/[id]/*.vue chia sẻ person hiện tại + isReadonly(tab) + onUpdated() xuống các tab con mà không phải truyền prop qua nhiều lớp.

use-hr-api.ts mở đầu bằng một helper nhỏ dùng lại ở gần như mọi hàm:

app/composables/use-hr-api.ts:1-6
export function useHrApi() {
const { $api } = useNuxtApp()
function unwrap<T>(res: T | { data: T }): T {
return (res && typeof res === 'object' && 'data' in res) ? (res as any).data : res
}

Lý do cần unwrap(): một số response backend đã có envelope { data }, một số route BFF trả thẳng object (khi $api tự unwrap từ tầng SDK). Thay vì mỗi hàm tự đoán shape, mọi lời gọi đều bọc qua unwrap() — kể cả khi giá trị vào là T thuần (không có field data), hàm trả nguyên nó.

Danh sách người là trường hợp không dùng unwrap() cho top-level vì cần giữ meta riêng:

app/composables/use-hr-api.ts:22-25
async function listPeople(query?: Record<string, string>) {
const res = await $api<any>('/hr/people', { query })
return { data: unwrap(res.data ?? res), meta: res.meta ?? null }
}

Hai hàm dưới đây là cầu nối sang Tích hợp Contacts — nằm chung file với CRUD người thường, không tách riêng:

app/composables/use-hr-api.ts:45-54
async function getPersonByContact(contactId: string | number) {
return unwrap(await $api<Person | null | { data: Person | null }>(`/hr/people/by-contact/${contactId}`))
}
async function convertFromContact(contactId: string | number, payload: { stage: string }) {
return unwrap(await $api<Person | { data: Person }>('/hr/people/from-contact', {
method: 'POST',
body: { contact_id: Number(contactId), ...payload },
}))
}

BFF pattern: assertPermission + customEndpoint proxy — nhưng stage-aware

Phần tiêu đề “BFF pattern: assertPermission + customEndpoint proxy — nhưng stage-aware”

Route BFF đơn giản (đọc) giống hệt helpdesk — chặn quyền cố định rồi proxy nguyên văn:

server/api/hr/people/index.get.ts (đầy đủ)
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => {
assertPermission(event, { module: 'hr', action: 'read' })
const userApi = event.context.userApi
const query = getQuery(event)
const result = await userApi.request(customEndpoint({
path: "/hr/people",
method: "GET",
params: query,
}))
return result
})

Route tạo người thì khác — action quyền không cố định mà tính theo stage trong body, vì tạo một talent (đội Recruiter) và tạo một probation/nhân viên (đội HR) đòi 2 permission khác nhau (recruitment.write vs create — xem Thiết lập):

server/api/hr/people/index.post.ts (đầy đủ)
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => {
const userApi = event.context.userApi
const body = await readBody(event)
// Stage-aware: creating a recruitment-stage person needs recruitment.write
const stage = body?.stage ?? 'talent'
const isRecruitment = ['talent', 'interviewing', 'offer'].includes(stage)
assertPermission(event, { module: 'hr', action: isRecruitment ? 'recruitment.write' : 'create' })
const result = await userApi.request(customEndpoint({
path: "/hr/people",
method: "POST",
body: JSON.stringify(body),
}))
return result
})

server/api/hr/people/from-contact.post.ts lặp lại đúng khuôn này (mặc định stage = 'probation' thay vì 'talent', vì luồng tạo-từ-contact trong HR tab thường dùng cho employee). Backend routes.ts cũng chặn quyền stage-aware lần thứ hai ở tầng route thật (guardStage) — BFF không phải là ranh giới quyền duy nhất, nó chỉ tránh cho client gọi thẳng backend mà không qua kiểm tra.

nuxt.config.ts của HR khai auto-import 2 thư mục không chuẩn Nuxt 4 (app/data, app/config), phục vụ setup-wizard schema (hr-schema.ts) và layout form khai báo — cùng một khuôn với module Contacts:

nuxt.config.ts (đầy đủ)
export default defineNuxtConfig({
compatibilityDate: "2025-01-15",
imports: {
dirs: [
resolve(__dirname, "app/data"),
resolve(__dirname, "app/config"),
],
},
i18n: {
locales: [
{ code: "en", file: "en.json" },
{ code: "vi", file: "vi.json" },
],
},
})