Employee — the HR person master record
The whole employee model reduces to one idea: an
Employeeis an HR "employment record" that wraps aUseraccount. TheUserholds the identity (name, email, phone, password, login role); theEmployeeholds the employment context (position, salary, statutory IDs, group assignments, reporting line) and is the anchor that every downstream HR document (leave, attendance, payroll, claim, loan, advance, timesheet, training) references byemployeeId.
Source: BE src/modules/hr/employee · Admin src/modules/employees, src/modules/hr/employees, src/modules/hr/employee-form, src/modules/hr/employee-detail, src/modules/hr/employee-create
1. Purpose & scope
The employee module owns the master record for a person employed by the company. It is responsible for:
- Creating/inviting the underlying
Useraccount (login identity) and linking it to anEmployeerow. - Storing the full employment data set: personal profile, employment terms, compensation setup, Malaysia statutory/tax fields, and the eight "group" / assignment references that wire the employee into the rest of HR.
- The employee lifecycle dates (join / confirm / resign).
- Bulk import (XLSX) and export.
- Acting as the hub that org-chart, payroll, leave, attendance, claim, loan, advance, timesheet, training, ESS and approvals all resolve against.
It explicitly does not:
- Compute payroll, leave balances, or attendance — those live in their own sub-modules (payroll, leave, attendance, etc.) and only read the employee + its group references.
- Manage the reporting hierarchy view — that is derived read-only by org-chart from the
reportingTofield. - Own statutory contribution definitions — see payroll (
EmployeeStatutory,EmployeePayrollItemare separate collections keyed byemployeeId).
2. Data model
2.1 employees collection
Schema: hr/employee/employee.schema.ts (class Employee extends BaseSchema, decorated @ApSchema({ collection: 'employees' }) — no hr_ prefix, unlike calendar/leave/etc.). Soft-delete via mongoose-delete (deletedAt, deletedBy).
BaseSchema (core/database/database.scheme.ts) contributes the common envelope on every row: _id, companyId, branchId (both indexed, multi-tenant scoping), documentCode, documentDate, createdAt/By, updatedAt/By, deletedAt/By, deleted, canUpdate/View/Delete/Post, client (default "zerp"), refId/ref2Id. All *Id setters coerce 24-char hex strings to ObjectId via BaseSchema.toObjectId.
| field | type | required | description |
|---|---|---|---|
ref |
string | yes, unique |
Employee ID / staff number. Also used to auto-link biometric device attendance records (see §8). |
userId |
ObjectId → users |
yes (set on create) | The link to the login identity. One Employee ⇄ one User. |
groupId |
ObjectId → permission_groups |
— | Access group (RBAC). Mirrored onto the User on create/update. See permissions-access. |
pin |
string | — | 4–6 digit PIN for ESS / device login. select: false (never returned by default; read via findOneForAuth which .select('+pin')). Set by admin via adminSetEmployeePin. |
reportingTo |
ObjectId → employees |
— | Self-reference: the employee's line manager. Source of the org-chart hierarchy and the approval routing (manager). |
hrId |
ObjectId → employees |
— | Self-reference: the assigned HR officer. Used in approval routing as the HR approver. |
departmentId |
ObjectId → departments |
— | Department membership. See department. |
jobId |
ObjectId | — | Job/role reference. Field exists and is exposed in GraphQL but has no resolver/lookup wired in the employee module — used by resolveByTargets (approval-policy targeting) only. Flag: lightly used. |
categoryId |
ObjectId | — | Category reference. Stored & exposed but no lookup wired. Flag: lightly used. |
calenderId |
ObjectId → hr_calendars(?) |
— | Per-employee calendar reference (note the spelling calenderId). Stored & exposed; not consumed by the calendar service, which resolves holidays per-company, not per-employee. Flag: present but unused. See calendar. |
| Group assignments | These five wire the employee into the corresponding HR engines. Each is a plain ObjectId, no resolver — downstream services read them directly. |
||
leaveGroupId |
ObjectId → hr_leave_groups |
— | Leave policy group. See leave. |
attendanceGroupId |
ObjectId → hr_attendance_groups |
— | Attendance/shift group. See attendance. |
claimGroupId |
ObjectId → hr_claim_groups |
— | Claim policy group. See claim. |
payrollContributionGroupId |
ObjectId → hr_payroll_contribution_groups |
— | Statutory contribution group. See payroll. |
payrollItemGroupId |
ObjectId | — | Payroll item group. Stored & exposed; lightly used. |
| Personal profile | |||
idNumber |
string (default "") |
— | National ID / passport number. |
dateOfBirth |
number (unix ms) | — | DOB. Drives statutory age categories in payroll. |
gender |
string enum EmployeeGender |
— | Stored as plain string (enum not registered in GraphQL — see §2.2). |
nationality |
string (default "") |
— | |
maritalStatus |
string (default "") |
— | Free-text in schema; admin form uses a master-data select. |
| Employment | |||
joinDate |
number (unix ms) | — | Hire date. |
confirmDate |
number (unix ms) | — | Confirmation (end of probation) date. |
resignDate |
number (unix ms) | — | Resignation/last-day date. There is no status enum field — active vs resigned is inferred from resignDate and soft-delete (see §4 lifecycle). |
employmentType |
string enum EmploymentType |
— | Plain string (enum not registered in GraphQL). |
position |
string (default "") |
— | Job title. Admin form resolves it against master data, stores the value. |
| Compensation / tax (Malaysia PCB) | |||
basicSalary |
number (default 0) |
— | Monthly basic salary — the payroll base. |
taxResidencyStatus |
enum TaxResidencyStatus (default RESIDENT) |
— | Resident vs non-resident — drives PCB tax method. GraphQL-registered enum. |
annualPersonalRelief |
number (default 0) |
— | Annual personal tax relief amount (configurable per employee). |
taxCategory |
enum TaxCategory (from payroll.constants) |
— | Marital/tax bracket category. GraphQL-registered enum. |
numberOfChildren |
number (default 0) |
— | Drives child relief in PCB calc. |
| Virtual / transient | |||
user |
User |
— | Populated via $lookupUser aggregation; not stored on the doc. |
isInvite |
boolean | — | Transient input flag (invite vs create flow); not persisted as a column. |
Multi-tenancy:
companyIdis the tenant boundary.validateExistcounts{ userId, companyId }so the sameUsercan be an employee in only one company.DepartmentService/etc. stampbranchIdfrom context; employee create persists whateverbranchIdis supplied.
2.2 Enums
// employee.schema.ts
export enum TaxResidencyStatus {
RESIDENT = "RESIDENT",
NON_RESIDENT = "NON_RESIDENT",
} // ← registered in GraphQL (employee.dto.ts)
export enum EmploymentType {
FULL_TIME = "FULL_TIME",
PART_TIME = "PART_TIME",
CONTRACT = "CONTRACT",
INTERN = "INTERN",
DOMESTIC_SERVANT = "DOMESTIC_SERVANT",
} // ← NOT registered in GraphQL; sent/stored as a plain String
export enum EmployeeGender {
MALE = "MALE",
FEMALE = "FEMALE",
OTHER = "OTHER",
} // ← NOT registered in GraphQL; sent/stored as a plain String// hr/payroll/payroll.constants.ts
export enum TaxCategory {
SINGLE = "SINGLE",
MARRIED_SPOUSE_WORKING = "MARRIED_SPOUSE_WORKING",
MARRIED_SPOUSE_NOT_WORKING = "MARRIED_SPOUSE_NOT_WORKING",
} // ← registered in GraphQLGotcha:
employmentTypeandgenderare typed asstringon the schema and asStringin GraphQL — the enums exist only as constants. The admin sources their option lists from master data, not from these enums, so values are validated only loosely.
2.3 Employee ⇄ User link (the core relationship)
User (users) Employee (employees)
─ identity ────────────── ─ employment ────────────────
_id ◀───────────────────── userId (FK, 1:1)
name, email, phoneNumber ref (staff no., unique)
password, kind=Staff groupId ──┐ mirrored to User
roles=[Staff] reportingTo ──┼─ self-refs (org/approval)
groupId ◀── kept in sync hrId ──┘
address, nationality, dob departmentId, position, basicSalary
status, active *GroupId (leave/attendance/claim/...)
taxResidencyStatus, taxCategory, ...
- The
Useris created withkind: UserKindTypes.Staffandroles: [UserRoleTypes.Staff](employee.service.ts → create).Useris a shared/discriminated schema (@ApSchema({ discriminatorKey: 'kind', shared: true })) — seeuser/user.schema.ts. groupIdis written to both the employee and the user, and kept in sync on update.reportingToandhrIdareEmployee._idreferences (not user ids). The approval orchestrator resolves them touserIdfor notification/routing (approval-orchestrator.service.ts).
2.4 Aggregation lookups
employee.schema.ts exports reusable $lookup stages used by the repository:
$lookupUser→users(always joined, givesuser).$combinedLookups= user + company + store + branch + group (permission_groups).findForExportadds department, leave/attendance/claim group, and payroll-contribution-group name lookups.
3. API surface
GraphQL (employee.resolver.ts)
All operations are guarded by @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature("HR_MODULE"). Mutations carry @AuditMeta (audit-trail snapshots).
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
createEmployee |
Mutation | CreateEmployeeInput |
Employee |
Creates user + employee (or invites if email exists). Audit CREATE. |
inviteEmployee |
Mutation | CreateEmployeeInput |
Employee |
Links an existing User as an employee. Audit CREATE. |
updateEmployee |
Mutation | id, UpdateEmployeeInput |
Employee |
Updates both employee and its user. Audit UPDATE. |
deleteEmployee |
Mutation | id |
Boolean |
Soft-delete. Audit DELETE. |
importEmployees |
Mutation | EmployeeImportInput { file } |
[EmployeeImportRow] |
Parses an uploaded XLSX into preview rows (no writes). |
confirmEmployeesImport |
Mutation | ConfirmEmployeeImportInput { employees[] } |
ConfirmEmployeeImportResult |
Persists reviewed rows; returns { created, skipped, errors[] }. Audit CREATE. |
findOneEmployee |
Query | EmployeeQueryInput { _id?, name?, email?, branchId? } |
Employee |
ignoreCompanyQuery: true — can resolve cross-company. |
employeePage |
Query | EmployeePageInput |
EmployeePageResult |
Paginated list; filters: keyword, branchId, departmentId, status. |
employeeByUserId |
Query | userId |
Employee |
ignoreCompanyQuery: true. Used by ESS / auth to resolve the employee behind a logged-in user. |
@ResolveField name returns user.name so list views can show the person's name without a nested user query.
EmployeePageInput (employee.dto.ts): skip, take, keyword?, branchId?, departmentId?, status?. Note: status is accepted by the input and repository buildQuery but there is no status column on the schema, so the filter matches nothing — effectively a no-op. Flag.
CreateEmployeeInput = EmployeeCommonInput + userId? + user: CreateUserInput. UpdateEmployeeInput = PartialType(EmployeeCommonInput) + user: UpdateUserInput. EmployeeCommonInput carries every persisted employment/personal/tax field plus all the group/assignment ids and isInvite — but not reportingTo, hrId is present, pin, or basicSalary's mirror on user. Note: reportingTo` is not in any input DTO (see §9).
REST (employee.controller.ts)
| Method | Route | Query | Response |
|---|---|---|---|
GET |
/api/employee/download |
downloadType=xlsx + report filters (keyword, …) |
Streams an XLSX "Employees Report" with the full denormalized column set (name, email, position, groups, tax fields, HR officer name, etc.). Auth via @ApiAuthorize(). |
4. Business rules & lifecycle
4.1 Create vs invite (de-duplication)
EmployeeService.create (employee.service.ts):
- If
model.user.emailmatches an existingUser→ callinvite({ ...model, userId: existingUser._id })instead of creating a duplicate account, then emitemployee.created. No new user is created. - Otherwise
userSvc.validateExist({ ...model, ...model.user })(rejects duplicate email/phone). - In a
withRetryTransaction("create_employee"):userSvc.create({ ...user, groupId, roles:[Staff], kind:Staff }).employeeRepo.create({ ...employeeFields, userId, groupId })(strips the nesteduser).
- Emit
employee.created→{ ref, employeeId, companyId }.
invite path: validateExist (one employee per user per company) → ensure user exists → create a minimal employee { groupId, branchId, userId } (note: invite does not copy the rest of EmployeeCommonInput — only those three fields are persisted). Flag: invite produces a sparse employee record.
4.2 Update
EmployeeService.update: load employee → Promise.all([ employeeRepo.update, userSvc.update(userId, { ...user, groupId }) ]) → merge → emit employee.created (re-fires to re-link any device attendance whose device number now matches the ref). There is no Mongo transaction wrapping the two writes (employee + user updated in parallel, not atomically). Flag.
4.3 Lifecycle / status
There is no explicit status state machine. An employee's "state" is implicit:
created ──▶ active ──▶ (resigned: resignDate set) ──▶ deleted (soft-delete)
- Active: row exists, not soft-deleted,
resignDateempty. - Confirmed:
confirmDateset (end of probation) — informational only. - Resigned:
resignDateset — informational; no automatic downstream gating found in the employee module. - Deleted:
deleteEmployeesoft-deletes (mongoose-delete); the row is excluded from all aggregations (deleted: { $ne: true }). hasDirectReports(employeeId)exists in the repository (counts non-deleted reports) but is not called as a delete guard in the employee service — deleting a manager will orphan their reports (org-chart treats orphans as roots). Flag.
4.4 Import
import(data): streams the XLSX, trims headers, maps tolerant column aliases (e.g. "Employee ID" | "Employee Ref" | "Ref") intoEmployeeImportRow[]with a generatedkeyandrowNumber(= index + 2). Dates viaparseDateToMs, numbers viaparseNumber. No DB writes — preview only.confirmImport(input): loops rows; skips any missingref,user.name, oruser.email(recorded as an error); elsecreate(...). Returns{ created, skipped, errors[] }. Each row goes through the full create/invite/dedupe path.
4.5 Side effects on write
- Emits
employee.createdon create/invite/update → consumed by attendance (attendance.service.ts @OnEvent("employee.created") linkDeviceRecords) to back-link biometric device swipes logged before the employee existed, matched byref. - Audit-trail snapshots on every mutation (
@AuditMeta module:"employee"). Userrow created/updated alongside the employee.
5. Permissions
- Feature gate:
@RequireFeature("HR_MODULE")+GqlFeatureGuard— the company's subscription must include the HR module. See subscription-config. - Auth:
@ApGqlAuthorize()(JWT).findOneEmployeeandemployeeByUserIduseignoreCompanyQuery: trueso they can resolve across the tenant boundary (needed for ESS/auth). - RBAC: the employee's
groupId(access group) governs what that user can do once logged in; managing employees themselves is governed by the caller's permission group. See permissions-access. - PIN: only set via the admin mutation
adminSetEmployeePin(admin-side); thepinisselect:falseand read only throughfindOneForAuth.
6. Flows
6.1 Create employee (happy path)
Admin /hr/employees → "Add Employee" → EmployeeForm (modal)
→ HREmployeeCreate.onSubmit
→ GQL createEmployee(employee: CreateEmployeeInput { user:{name,email,...}, position, groupId, ... })
→ EmployeeResolver.create
→ EmployeeService.create
├─ email exists? → invite(existingUserId) ─────────────┐
└─ else: tx { userSvc.create(kind=Staff) → employeeRepo.create(userId) }
→ emit employee.created → attendance.linkDeviceRecords(ref) │
→ audit CREATE snapshot │
← Employee (with user) ◀────────────────────────────────────┘
6.2 Invite existing user
Admin → Invite tab → search user by email → inviteEmployee(employee{ userId, groupId, branchId })
→ EmployeeService.invite → validateExist({userId,companyId}) → employeeRepo.create({groupId,branchId,userId})
← Employee (sparse: only those 3 fields persisted)
6.3 Bulk import
Admin /hr/employees/import → upload XLSX
→ importEmployees(file) → [EmployeeImportRow] (preview, editable in a table)
→ user reviews/edits rows
→ confirmEmployeesImport(employees[])
for each row: missing ref/name/email? → skip+error ; else create()
← { created, skipped, errors[] }
6.4 Unhappy paths
- Duplicate email on create → silently converted to invite (existing user linked), not an error.
validateExistfail (already an employee in this company) → throws"Employee already exists in this company".- Update with bad id →
"User not found". - Import row missing required field → counted in
skippedwith anerrors[]entry; the rest continue.
7. Admin UI
The admin has two employee surfaces sharing the same context/GraphQL:
| Area | Route | Module |
|---|---|---|
| Legacy "staff" list (sales/maintenance) | /employee |
src/modules/employees |
| HR people list + rich detail | /hr/employees, /hr/employees/[id] |
src/modules/hr/employees, src/modules/hr/employee-detail |
| Bulk import wizard | /hr/employees/import |
(employees module import flow) |
Context methods (src/modules/employees/context.tsx)
fetchEmployeePage, createEmployee, updateEmployee, deleteEmployee, inviteEmployee, findOneEmployee, searchEmployees, fetchAllEmployees (up to 500), importEmployees, confirmEmployeesImport. GraphQL op aliases in gql/query.ts: CREATE_STAFF/INVITE_EMPLOYEE/UPDATE_STAFF/STAFF_PAGE/FIND_ONE_STAFF/DELETE_STAFF/IMPORT_EMPLOYEES/CONFIRM_EMPLOYEES_IMPORT.
Shared form (src/modules/hr/employee-form/employee-form.tsx)
One reusable Formik form used by both create and edit (modal or full-page variant). Six sections: Identifiers (Employee ID, HR Officer select), Contact (name, email, phone, address), Employment (position, department, employment type, join date, confirm date, branch), Personal (gender, DOB, nationality, marital status, ID/passport), Organization & Groups (leave / attendance / claim / payroll-contribution group selects), Tax (residency, annual personal relief, tax category, number of children).
Yup required fields: name, email (valid email), position, employmentType, joinDate. Everything else optional. position, employmentType, gender, nationality, maritalStatus are resolved against master data (findMasterItem handles legacy stored names). Helpers: mapEmployeeToFormValues, mapFormValuesToCreatePayload, mapFormValuesToUpdatePayload, useEmployeeFormOptions (loads groups/departments/branches/employees/masters).
The form does not expose
reportingTo(line manager) — onlyhrId(HR officer). The reporting line that drives the org-chart is not editable in the standard employee form. Flag (see §9).
Employee detail page (src/modules/hr/employee-detail)
Tabbed read-through of everything keyed to the employee. Tabs lazy-load on click (tracked in a fetchedTabs set; persisted via ?tab= query param):
- Overview — 6 info cards (Contact, Employment, Personal, Organisation, Group Assignments, Tax); warns + "Assign now" if no leave group.
- Leaves / Attendance / Timesheets — dual table/calendar view.
- Claims / Advances / Loans — table.
- Payroll Items / Statutory / EA Form / Payroll — embedded payroll sub-modules.
Header: avatar, name, ref badge, position/employment-type/branch badges, joined date, access group, Set PIN (adminSetEmployeePin, 4–6 digits, confirm match) and Edit (opens EmployeeForm in modal). Detail GraphQL ops: FIND_EMPLOYEE, UPDATE_EMPLOYEE, ADMIN_SET_PIN, EMPLOYEE_LEAVES/CLAIMS/ADVANCES/LOANS/ATTENDANCE/TIMESHEETS/PAYROLL_RECORDS.
Notable UX: download report (PDF/XLSX), search, two creation modes (new vs invite), password field only on create.
8. Dependencies & integrations
Employee depends on / calls:
UserModule(UserService) — creates/updates the linked user. Hard dependency.permission_groups(access group) — RBAC link.SubscriptionModule(feature gate),AuthModule,ApConfigModule,AccountModule(imported by the module).
Consumed by (keyed on employeeId = Employee._id):
- org-chart — reads
reportingTo,departmentId,hodId. - payroll —
EmployeePayroll,EmployeePayrollItem,EmployeeStatutory, contribution groups; readsbasicSalary, tax fields,dateOfBirth. - leave, attendance, timesheet, claim, loan, advance, training, ess, approvals — all reference
employeeId(confirmedemployeeIdin each schema).
Events:
- Emits
employee.created→ attendance device-record back-linking.
Cron/external: none in the employee module itself (XLSX via zync-nest-library / XlsxUtils).
9. Gotchas & project-specific rules
- Collection name has no
hr_prefix.employeesanddepartmentsuse@ApSchema(plain), while calendar/leave/etc. use@HrSchema(prefixedhr_*). Don't assume a uniform prefix. reportingTois not in any GraphQL input/DTO. It is on the schema and read by org-chart + approvals, but the only place it is written in the codebase is the data-fix migration2026-06-19-fix-msgold-employee-ids.ts. The standard create/update/import paths cannot set it. To build a hierarchy from the app you must addreportingToto the inputs/form, or import/migrate it directly. Confirmed unimplemented as a user-editable field.reportingTo/hrIdareEmployee._id, notUser._id. The approval orchestrator explicitly re-resolves them touserId.statusfilter is a dead param — accepted byEmployeePageInputandbuildQuerybut there is nostatuscolumn, so it never matches. Active/resigned must be derived fromresignDate.- Create de-dupes silently into invite when the email already exists — no error surfaced.
invitepersists only{ groupId, branchId, userId }— all other employment fields on the invite payload are dropped. Sparse record results.- Update is not transactional (employee + user updated via
Promise.all, no Mongo session); a partial failure can desync the two rows. - No delete guard for managers with reports —
hasDirectReportsexists but isn't enforced; deleting a manager orphans reports (they become org-chart roots). calenderId,jobId,categoryId,payrollItemGroupIdare stored & GraphQL-exposed but lightly/never consumed by their own module — present for forward use or used only by targeting (resolveByTargets).employmentType/genderenums aren't GraphQL-registered — they pass as free strings; the admin validates them via master-data lists, not the enums.- Dates are unix-ms numbers throughout (
joinDate,confirmDate,resignDate,dateOfBirth), coerced byBaseSchema.toUnixTimestamp.