Employee — the HR person master record

The whole employee model reduces to one idea: an Employee is an HR "employment record" that wraps a User account. The User holds the identity (name, email, phone, password, login role); the Employee holds 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 by employeeId.

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 User account (login identity) and linking it to an Employee row.
  • 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 reportingTo field.
  • Own statutory contribution definitions — see payroll (EmployeeStatutory, EmployeePayrollItem are separate collections keyed by employeeId).

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: companyId is the tenant boundary. validateExist counts { userId, companyId } so the same User can be an employee in only one company. DepartmentService/etc. stamp branchId from context; employee create persists whatever branchId is 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 GraphQL

Gotcha: employmentType and gender are typed as string on the schema and as String in 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.

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 User is created with kind: UserKindTypes.Staff and roles: [UserRoleTypes.Staff] (employee.service.ts → create). User is a shared/discriminated schema (@ApSchema({ discriminatorKey: 'kind', shared: true })) — see user/user.schema.ts.
  • groupId is written to both the employee and the user, and kept in sync on update.
  • reportingTo and hrId are Employee._id references (not user ids). The approval orchestrator resolves them to userId for notification/routing (approval-orchestrator.service.ts).

2.4 Aggregation lookups

employee.schema.ts exports reusable $lookup stages used by the repository:

  • $lookupUserusers (always joined, gives user).
  • $combinedLookups = user + company + store + branch + group (permission_groups).
  • findForExport adds 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):

  1. If model.user.email matches an existing User → call invite({ ...model, userId: existingUser._id }) instead of creating a duplicate account, then emit employee.created. No new user is created.
  2. Otherwise userSvc.validateExist({ ...model, ...model.user }) (rejects duplicate email/phone).
  3. In a withRetryTransaction("create_employee"):
    • userSvc.create({ ...user, groupId, roles:[Staff], kind:Staff }).
    • employeeRepo.create({ ...employeeFields, userId, groupId }) (strips the nested user).
  4. 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, resignDate empty.
  • Confirmed: confirmDate set (end of probation) — informational only.
  • Resigned: resignDate set — informational; no automatic downstream gating found in the employee module.
  • Deleted: deleteEmployee soft-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") into EmployeeImportRow[] with a generated key and rowNumber (= index + 2). Dates via parseDateToMs, numbers via parseNumber. No DB writes — preview only.
  • confirmImport(input): loops rows; skips any missing ref, user.name, or user.email (recorded as an error); else create(...). Returns { created, skipped, errors[] }. Each row goes through the full create/invite/dedupe path.

4.5 Side effects on write

  • Emits employee.created on 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 by ref.
  • Audit-trail snapshots on every mutation (@AuditMeta module:"employee").
  • User row 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). findOneEmployee and employeeByUserId use ignoreCompanyQuery: true so 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); the pin is select:false and read only through findOneForAuth.

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.
  • validateExist fail (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 skipped with an errors[] 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) — only hrId (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):

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

  1. Collection name has no hr_ prefix. employees and departments use @ApSchema (plain), while calendar/leave/etc. use @HrSchema (prefixed hr_*). Don't assume a uniform prefix.
  2. reportingTo is 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 migration 2026-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 add reportingTo to the inputs/form, or import/migrate it directly. Confirmed unimplemented as a user-editable field.
  3. reportingTo / hrId are Employee._id, not User._id. The approval orchestrator explicitly re-resolves them to userId.
  4. status filter is a dead param — accepted by EmployeePageInput and buildQuery but there is no status column, so it never matches. Active/resigned must be derived from resignDate.
  5. Create de-dupes silently into invite when the email already exists — no error surfaced.
  6. invite persists only { groupId, branchId, userId } — all other employment fields on the invite payload are dropped. Sparse record results.
  7. Update is not transactional (employee + user updated via Promise.all, no Mongo session); a partial failure can desync the two rows.
  8. No delete guard for managers with reportshasDirectReports exists but isn't enforced; deleting a manager orphans reports (they become org-chart roots).
  9. calenderId, jobId, categoryId, payrollItemGroupId are stored & GraphQL-exposed but lightly/never consumed by their own module — present for forward use or used only by targeting (resolveByTargets).
  10. employmentType / gender enums aren't GraphQL-registered — they pass as free strings; the admin validates them via master-data lists, not the enums.
  11. Dates are unix-ms numbers throughout (joinDate, confirmDate, resignDate, dateOfBirth), coerced by BaseSchema.toUnixTimestamp.