ESS — Employee Self-Service portal

The whole ESS model reduces to one idea: an employee signs in with their Employee ID (ref) + PIN — not a User login — and receives a dedicated ESS JWT whose role: 'EMPLOYEE' claim carries the employeeId. From then on the same HR resolvers are reused, but every HR repository's buildQuery() silently injects employeeId = contextSvc.employeeId, so an employee only ever sees their own leave / claims / attendance / payslips. ESS is therefore not a new data layer — it is a second authentication path + an automatic per-employee row filter layered over the existing HR modules, served through a separate mobile-first PWA shell.

Source: BE src/modules/hr/ess · Guard src/core/guards/ess-authorize.guard.ts · Admin src/modules/ess (auth, layout, utils, profile, pwa) + pages src/pages/ess/* + API routes src/pages/api/ess/*


1. Purpose & scope

ESS lets a rank-and-file employee — who has no admin login — self-serve their own HR data from a phone:

  • Authenticate with Employee ID + PIN (no email/password), getting a short-lived access token (8h) + long-lived refresh token (30d).
  • View their profile (essMe): name, position, branch, company, employment details.
  • See & submit their own leave, claim, advance, loan, timesheet requests (reusing the standard HR mutations, scoped to themselves).
  • Read-only their own attendance, payslips, calendar, training.
  • Clock in/out via QR (qrClockIn) by scanning a rotating QR code.
  • Managers (employees with direct reports) additionally get an approvals inbox.

It explicitly does NOT:

  • Define its own HR documents. Leave/claim/attendance/payroll/training schemas live in their own modules (leave, claim, attendance, payroll, training). ESS owns only the auth DTOs/results and the QR-token helpers — it has no schema/collection of its own.
  • Replace admin HR management. Admins still use the full /hr/* admin modules; ESS is the employee-facing window.
  • Set a user's RBAC/access group. ESS access is gated purely by the role: 'EMPLOYEE' JWT claim, not by permission_groups.

Key data dependency: ESS reads two fields off the employee record — ref (the login ID) and pin (the bcrypt-hashed PIN, select: false). The PIN is set by an admin via adminSetEmployeePin (employee detail page → "Set PIN") or by the employee via essSetPin. "is manager" is derived live from EmployeeRepository.hasDirectReports.


2. Data model

ESS has no Mongo collection. It defines GraphQL DTOs only (ess.dto.ts). The two persisted fields it relies on live on employees:

field (on employees) type description
ref string, unique The Employee ID the employee types to sign in (e.g. EMP001).
pin string, select: false bcrypt hash (10 salt rounds) of the 4–6 digit PIN. Never returned by default queries; read only via EmployeeRepository.findOneForAuth which .select('+pin').
reportingTo ObjectId → employees Drives "is manager" — an employee with ≥1 direct report is treated as a manager (hasDirectReports).

2.1 Input DTOs (ess.dto.ts)

@InputType() class EssSignInInput {
  employeeRef: string;   // matches Employee.ref ; @IsString @IsNotEmpty
  pin: string;           // @IsString @MinLength(4)
}
@InputType() class EssRefreshInput {
  employeeId: string;    // @IsNotEmpty
  refreshToken: string;  // @IsNotEmpty
}
@InputType() class EssSetPinInput {
  pin: string;           // @MinLength(4) — sets the caller's own PIN
}
@InputType() class AdminSetEmployeePinInput {
  employeeId: string;    // @IsNotEmpty — admin sets another employee's PIN
  pin: string;           // @MinLength(4)
}

2.2 Result types

@ObjectType() class EssAuthResult {        // returned by essSignIn / essRefreshToken
  accessToken: string;
  refreshToken: string;
  employeeId: string;
  name: string;
  isManager: boolean;
}
@ObjectType() class AttendanceQrResult {   // returned by attendanceQrToken (the rotating QR)
  token: string;
  expiresAt: number;     // unix ms
}
@ObjectType() class QrClockInResult {       // returned by qrClockIn
  kind: string;          // 'CLOCK_IN' | 'CLOCK_OUT' (alternates automatically)
  time: number;          // unix ms
}
@ObjectType() class EssProfileResult {      // returned by essMe
  _id, ref, name, email, phoneNumber, position, employmentType,
  joinDate, gender, nationality, maritalStatus, dateOfBirth, idNumber,
  branchName, companyName, isManager;
}

All scalar enums (employmentType, gender) come through as plain strings on EssProfileResult — same loose typing as the employee schema (those enums aren't GraphQL-registered).


3. API surface

All ESS GraphQL lives in ess.resolver.ts. Note: the resolver uses @ApGqlAuthorize() (the standard passport-jwt guard) — not the bespoke @EssAuthorize() decorator. Because the ESS access token is signed with the same jwt_access_token_secret, the normal GqlAuthGuard validates it and contextSvc.setUser(payload) puts employeeId/companyId/branchId into context. (The EssAuthorizeGuard/@EssAuthorize() decorator exists and is exported by EssModule, but is currently unused on these resolvers — see §9.)

Operation Type Input Returns Auth
essSignIn Mutation EssSignInInput EssAuthResult authNotRequired: true (public) — audit STATUS_CHANGE on employees
essRefreshToken Mutation EssRefreshInput EssAuthResult authNotRequired: true (public) — audit STATUS_CHANGE
essMe Query EssProfileResult @ApGqlAuthorize() (reads contextSvc.employeeId)
essSetPin Mutation EssSetPinInput Boolean @ApGqlAuthorize() — sets the caller's own PIN; audit UPDATE
adminSetEmployeePin Mutation AdminSetEmployeePinInput Boolean @ApGqlAuthorize() — admin sets any employee's PIN; audit UPDATE
attendanceQrToken Query AttendanceQrResult authNotRequired: true (public) — used by the admin-side QR display
qrClockIn Mutation token: String QrClockInResult @ApGqlAuthorize() — clocks the calling employee; audit CREATE on attendances

Everything else an employee sees (leave list, claim list, payslips, etc.) reuses the standard HR queries/mutations of each sub-module — no ESS-specific operation. The per-employee filtering happens at the repository layer (§4.2), invisible to the GraphQL contract.

REST / Next.js API routes (src/pages/api/ess/*)

Route Method Purpose
/api/ess/session POST / GET / DELETE Stores the EssAuthResult JSON in an ess-token cookie (8h maxAge, httpOnly:false, sameSite:lax). The Apollo auth link and SSR guards read it.
/api/ess/company GET ?code= Resolves a company by ref (logo/name) for the branded login screen (server-to-server GraphQL).
/api/ess/manifest GET Serves the PWA manifest+json (name: Mabiz ESS, start_url: /ess, display: standalone).
/api/ess/qr-token GET Proxies attendanceQrToken for the rotating attendance QR.
/api/ess/training/certificate GET Training certificate download (see training).

4. Business rules & calculations

4.1 Authentication (EssService.signIn)

async signIn({ employeeRef, pin }) {
  const employee = await employeeRepo.findOneForAuth({ ref: employeeRef });   // .select('+pin')
  if (!employee)        throw Unauthorized('Invalid employee ID or PIN');
  if (!employee.pin)    throw Unauthorized('PIN not set. Contact HR to set your PIN.');
  if (!await bcrypt.compare(pin, employee.pin))
                        throw Unauthorized('Invalid employee ID or PIN');     // same generic msg
  const isManager = await employeeRepo.hasDirectReports(employee._id);
  return buildTokens(employee, resolveName(employee), isManager);
}

Rules / invariants:

  • Lookup is by ref only (not scoped by company in the current code — the plan originally scoped by contextSvc.companyId, but signIn is public/authNotRequired, so there is no company in context). ref must be globally resolvable.
  • Generic error message for both "no employee" and "wrong PIN" (anti-enumeration). A distinct message only when the PIN was never set.
  • PIN min length 4 (@MinLength(4)); admin UI allows 4–6 digits.
  • isManager = hasDirectReports(employeeId) (counts non-deleted employees whose reportingTo = this id). Because reportingTo is not user-editable in the standard employee form (see employee §9), most employees resolve to isManager: false unless reportingTo was set via migration/import.

4.2 The employee-scoping mechanism (the heart of ESS)

ESS reuses the standard HR resolvers; the per-employee filter is applied in each HR repository's buildQuery():

// e.g. leave.repository.ts (and claim/advance/loan/attendance/timesheet/payroll)
const effectiveQuery = this.contextSvc.employeeId
  ? { ...query, employeeId: this.contextSvc.employeeId }   // ESS request → force own rows
  : query;                                                  // admin request → unscoped
  • When the request carries an ESS token, GqlAuthGuard set contextSvc.employeeId from the JWT, so buildQuery pins every read/page to that employeeId. An employee physically cannot query another employee's rows.
  • When the request carries an admin token (no employeeId claim), effectiveQuery is untouched and the admin sees everything.
  • This applies to: leave, claim, advance, loan, attendance, timesheet, payroll (per the ESS backend plan, Tasks 8–9). The approval repository additionally supports a manager view (approverId vs requesterId).

This is why no ESS-specific GraphQL operations exist for those modules: one resolver serves both admin and employee, and the only difference is whether contextSvc.employeeId is present.

4.3 Token issuance (buildTokens)

accessToken  = jwt.sign({ employeeId, companyId, branchId, isManager, role:'EMPLOYEE' },
                        { secret: jwt_access_token_secret,  expiresIn: '8h' });
refreshToken = jwt.sign({ employeeId, type:'refresh' },
                        { secret: jwt_refresh_token_secret, expiresIn: '30d' });
  • Access token TTL 8h, refresh TTL 30d.
  • role: 'EMPLOYEE' is the discriminator the EssAuthorizeGuard checks; it also distinguishes ESS tokens from admin tokens in the context.
  • refreshToken (EssService.refreshToken): verifies with jwt_refresh_token_secret, requires payload.type === 'refresh' and payload.employeeId === input.employeeId (else Forbidden), re-loads the employee, re-issues both tokens.

4.4 PIN management

  • essSetPin(pin)setPin: bcrypt-hash, employeeRepo.update(contextSvc.employeeId, { pin }). Self-service.
  • adminSetEmployeePin({ employeeId, pin })adminSetPin: validates the employee exists, bcrypt-hash, update. Admin-only entry point (from employee detail page).

4.5 QR clock-in (generateQrToken + qrClockIn)

generateQrToken(): token = jwt.sign({ type:'attendance_qr' }, { expiresIn:'25s' });
                   expiresAt = Date.now() + 25_000;       // QR rotates every 25s

qrClockIn(token):
  payload = jwt.verify(token);                            // expired → 'QR code has expired…'
  if (payload.type !== 'attendance_qr') → 'Invalid QR code';
  record = attendanceSvc.clockIn({ employeeId: contextSvc.employeeId,
                                   time:now, date:now, submitType: AttendanceSubmitType.QR });
  return { kind: record.kind, time: record.time };
  • The QR token is short-lived (25s) and carries no employee identity — it only proves the scan is fresh. The employee identity comes from the scanner's own ESS token (contextSvc.employeeId). So the flow is: admin/kiosk displays a rotating QR (attendanceQrToken, public), the employee scans it inside the ESS app (authenticated), and qrClockIn records attendance for that employee.
  • clockIn auto-alternates kind: if the last record today is CLOCK_IN, the next is CLOCK_OUT, and vice-versa (logic in attendance attendance.service.ts). submitType: QR distinguishes it from DEVICE / MANUAL.

5. Permissions

  • No RBAC / permission_groups involvement. ESS access is gated solely by a valid ESS JWT with role: 'EMPLOYEE'.
  • EssAuthorizeGuard (src/core/guards/ess-authorize.guard.ts) is the intended gate: it requires Bearer <token>, verifies it with jwt_access_token_secret, rejects if role !== 'EMPLOYEE' ('ESS access only') or if employeeId/companyId claims are missing, then contextSvc.setUser({ employeeId, companyId, branchId, activeBranchId }). Exposed as the @EssAuthorize() decorator.
  • Current state: the ESS resolver uses @ApGqlAuthorize() instead, which works because the ESS token shares the access-token secret and passport-jwt accepts it (the JwtStrategy.validate simply returns the payload). The @EssAuthorize() decorator is wired/exported but not applied on the resolvers — see §9.
  • Feature gate: the ESS auth resolver itself is not behind @RequireFeature('HR_MODULE') (auth must work standalone). The downstream HR resolvers an employee then calls are HR-feature-gated, so an ESS user in a company without the HR subscription would be blocked at those resolvers.

6. Flows

6.1 Sign in

1. Employee opens /ess/login (Mabiz ESS PWA shell)
     → optional branded company lookup: GET /api/ess/company?code=<ref>
2. Enters Employee ID + PIN  (Formik: employeeRef required, pin min 4)
     → EssAuthContext.signIn → GQL essSignIn(input)            [public, no token]
     → EssService.signIn: findOneForAuth(ref) → bcrypt.compare(pin) → hasDirectReports
     → EssAuthResult { accessToken, refreshToken, employeeId, name, isManager }
3. Client POSTs the result to /api/ess/session  → sets `ess-token` cookie (8h)
4. setSession(...) ; router.replace(next ?? '/ess/dashboard')

Unhappy paths: unknown ref / wrong PIN → 'Invalid employee ID or PIN'; PIN never set → 'PIN not set. Contact HR…'. SSR guard getServerSideProps on /ess/login redirects to /ess/dashboard if ess-token already present.

6.2 Authenticated read (e.g. "My Leave")

1. /ess/leave → ApolloClientEss authLink reads accessToken from `ess-token` cookie → Authorization: Bearer <ESS token>
2. Standard LEAVE_PAGE query hits LeaveResolver (@RequireFeature HR_MODULE)
3. GqlAuthGuard validates token → contextSvc.setUser({ employeeId, companyId, branchId })
4. LeaveRepository.buildQuery injects { employeeId } → returns ONLY this employee's leaves

The same path serves every reused HR module (claim/advance/loan/attendance/timesheet/payroll).

6.3 Submit a request (with offline support)

1. /ess/leave-apply (or claim/advance) → Formik form
2a. Online  → standard createLeave/createClaim/createAdvance mutation → enters HR approval engine (PENDING_APPROVAL) [§HR overview §4]
2b. Offline → outbox.push({ type:'leave'|'advance'|'claim', input }) into IndexedDB (`ess-offline-outbox`)
3. On `window 'online'` event → EssLayout calls syncOutbox() → drains queued mutations via essApolloClient → toast "<n> pending requests submitted"

Offline queue covers leave, advance, claim only (outbox.ts mutationMap).

6.4 QR clock-in

1. Admin/kiosk shows /ess/attendance-scan-style rotating QR via attendanceQrToken (25s TTL)
2. Employee opens /ess/qr-scan?token=<t> (camera scan with html5-qrcode, or follows the QR's deep link)
     SSR guard: no `ess-token` → redirect to /ess/login?next=/ess/qr-scan?token=...
3. AttendanceContext.qrClockIn(token) → GQL qrClockIn(token)
     → verify token fresh (25s) + type=attendance_qr → attendanceSvc.clockIn(employeeId, QR)
     → kind auto-alternates CLOCK_IN ↔ CLOCK_OUT
4. UI shows "Clocked In/Out" + time → redirect to /ess/attendance

Unhappy: stale/invalid token → 'QR code has expired. Please scan the latest code.' / 'Invalid QR code'.

6.5 Manager approvals

isManager → EssBottomNav swaps the 5th tab from "Payroll" to "Approvals"
/ess/approvals → manager inbox (approverId-scoped) → approve/reject  [see HR approvals]

7. Admin UI (the ESS PWA)

ESS is delivered as a separate mobile-first PWA inside the same Next.js admin app, under /ess/*, with its own Apollo client, auth context, layout, and styling (ess.css). It is not the admin chrome.

7.1 Pages (src/pages/ess/*)

Route Purpose
/ess (index) Entry redirect.
/ess/login Employee ID + PIN sign-in (branded, PWA-installable).
/ess/dashboard Home — greeting header + quick-link grid (dashboard-quicklinks.tsx).
/ess/profile Profile (essMe) + Set PIN / sign-out.
/ess/leave, /ess/leave/leave-apply My leave list + apply form.
/ess/claims, /ess/claims/claims-create My claims + create.
/ess/advance, /ess/advance/advance-request My advances + request.
/ess/loan, /ess/loan/loan-apply My loans + apply.
/ess/attendance, /ess/attendance-scan, /ess/qr-scan Attendance log + QR scanner + scan-result.
/ess/timesheet, /ess/timesheet/timesheet-log Timesheet list + log hours.
/ess/payroll My payslips.
/ess/calendar Company calendar / holidays.
/ess/training, /ess/training/training Assigned training + progress (see training).
/ess/approvals Manager approvals inbox (manager-only).

7.2 Separate Apollo client (ApolloClientEss.tsx)

A dedicated essApolloClient: authLink reads accessToken from the ess-token cookie (parsed JSON) and sets Authorization: Bearer <token>. This is what makes the same HR GraphQL endpoints run under the employee's identity. fetchPolicy: cache-and-network.

7.3 Auth context (auth/context.tsx + gql/query.ts)

EssAuthContextProvider holds the IEssSession ({ employeeId, name, isManager, accessToken, refreshToken }):

  • On mount → GET /api/ess/session to hydrate from cookie.
  • signIn(employeeRef, pin)essSignIn mutation → POST /api/ess/sessionsetSession.
  • signOut()DELETE /api/ess/session → clear.
  • useEssAuthState() is the only consumer; useEssAuthQuery() wraps essSignIn / essRefreshToken.

profile/context.tsx (EssProfileContextProvider) wraps essMe and exposes useEssProfileState().

7.4 Layout (layout/*)

  • EssLayout — wraps children in <ApolloProvider client={essApolloClient}> + EssAuthContextProvider; max-w-md mobile shell; sets PWA <meta> + manifest; on window 'online' drains the offline outbox.
  • EssHeader — greeting mode (home: avatar + "Good morning, {name}") vs page-title mode (centered title + left/right slots).
  • EssBottomNav — 5 tabs: Home, Leave, Time, Claims, and a 5th that is Approvals if isManager else Payroll.

7.5 Utils (utils/*)

  • ess-guard.tsrequireEssSession(ctx): SSR guard; redirects to /ess/login if no ess-token cookie. Used in each protected ESS page's getServerSideProps.
  • dashboard-quicklinks.tsx — the 9 quick links (Leave, Time, Claims, Payroll, Training, Advance, Loan, Timesheet, Calendar) + date formatters + approval KIND_LABEL/KIND_CLASS/STATUS_CLASS/HREF maps.
  • badge-class.ts — status → badge CSS class.

7.6 PWA (pwa/*)

  • outbox.ts — IndexedDB offline queue (ess-offline-outbox/mutations); push/getAll/remove + syncOutbox() drains queued leave/advance/claim mutations.
  • usePwaInstall.ts + EssPwaInstallBanner.tsx — "Add to Home Screen" prompt.
  • useNetworkStatus.ts + EssOfflineBanner.tsx — online/offline indicator.
  • /api/ess/manifest — the web app manifest (Mabiz ESS, standalone, theme #014473).

8. Dependencies & integrations

BE — EssModule imports: JwtModule, forwardRef(AuthModule), forwardRef(EmployeeModule), forwardRef(AttendanceModule). Providers: EssService, EssResolver, EssAuthorizeGuard. Mounted under HrModule.

  • Employee (employee) — EmployeeRepository.findOneForAuth (PIN read), hasDirectReports (manager check), update (PIN write). Hard dependency.
  • Attendance (attendance) — AttendanceService.clockIn for QR clock-in (submitType: QR).
  • Auth / Jwt — token signing/verification with jwt_access_token_secret (8h) + jwt_refresh_token_secret (30d).
  • All scoped HR modules (leave/claim/advance/loan/timesheet/payroll/approval) — not imported by EssModule; the coupling is via contextSvc.employeeId injected into their repositories' buildQuery (ESS backend plan, Tasks 8–10).
  • No cron, no external services beyond the camera (html5-qrcode) and the browser IndexedDB/PWA APIs on the admin side.

Admin — separate stack: own essApolloClient, own auth/session cookie (ess-token), own layout. The branded login calls GET /api/ess/company (server-to-server GraphQL findOneCompany).


9. Gotchas & project-specific rules

  1. ESS tokens share the access-token secret. Because essSignIn signs the access token with jwt_access_token_secret, the standard GqlAuthGuard accepts ESS tokens — which is why the ESS resolver uses @ApGqlAuthorize() and why employees can call the regular HR resolvers. The dedicated @EssAuthorize() guard is implemented/exported but not applied on any resolver in the current code. (The plan intended @EssAuthorize() on essSetPin.)
  2. Per-employee filtering is implicit in repositories, not the API. There are no myLeave/myClaim operations — the same resolver serves admin and employee, and buildQuery injects employeeId only when an ESS token is present. Forget the buildQuery change in a new HR repo and an ESS user would see everyone's rows. This is the single most important rebuild rule.
  3. signIn lookup is by ref alone, company-unscoped. Current code calls findOneForAuth({ ref }) with no companyId (the operation is public, so no company in context). ref must therefore be unique enough to resolve across tenants. (The original plan scoped by contextSvc.companyId — diverged.)
  4. isManager depends on reportingTo, which is not user-editable. Since reportingTo is absent from the employee create/update form (set only by migration — see employee §9), most employees get isManager: false and never see the Approvals tab unless reportingTo was populated externally.
  5. QR token carries no identity; the scanner's token does. The 25-second QR proves freshness only; qrClockIn records attendance for contextSvc.employeeId (the scanning employee). A QR cannot be used to clock in someone else.
  6. ess-token cookie is httpOnly: false — deliberately readable by the Apollo authLink in the browser. The cookie's maxAge (8h) matches the access-token TTL, but there is no automatic refresh wired into the Apollo client in the current code (the refresh mutation exists but the link does not call it on 401).
  7. Offline outbox covers only leave/advance/claim. Other submissions (timesheet, loan) are online-only; they are not queued by outbox.ts.
  8. PIN is select: false everywhere except findOneForAuth. Don't expect it on a normal employee query, and never return it.
  9. Branding is hardcoded to "Mabiz ESS" in the layout/manifest/login (title, name, theme color #014473). Re-skin per tenant if reused.