ESS — Employee Self-Service portal
The whole ESS model reduces to one idea: an employee signs in with their Employee ID (
ref) + PIN — not aUserlogin — and receives a dedicated ESS JWT whoserole: 'EMPLOYEE'claim carries theemployeeId. From then on the same HR resolvers are reused, but every HR repository'sbuildQuery()silently injectsemployeeId = 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 bypermission_groups.
Key data dependency: ESS reads two fields off the employee record —
ref(the login ID) andpin(the bcrypt-hashed PIN,select: false). The PIN is set by an admin viaadminSetEmployeePin(employee detail page → "Set PIN") or by the employee viaessSetPin. "is manager" is derived live fromEmployeeRepository.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 onEssProfileResult— 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
refonly (not scoped by company in the current code — the plan originally scoped bycontextSvc.companyId, butsignInis public/authNotRequired, so there is no company in context).refmust 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-deletedemployeeswhosereportingTo= this id). BecausereportingTois not user-editable in the standard employee form (see employee §9), most employees resolve toisManager: falseunlessreportingTowas 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,
GqlAuthGuardsetcontextSvc.employeeIdfrom the JWT, sobuildQuerypins every read/page to thatemployeeId. An employee physically cannot query another employee's rows. - When the request carries an admin token (no
employeeIdclaim),effectiveQueryis 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 (
approverIdvsrequesterId).
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.employeeIdis 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 theEssAuthorizeGuardchecks; it also distinguishes ESS tokens from admin tokens in the context.refreshToken(EssService.refreshToken): verifies withjwt_refresh_token_secret, requirespayload.type === 'refresh'andpayload.employeeId === input.employeeId(elseForbidden), 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), andqrClockInrecords attendance for that employee. clockInauto-alternateskind: if the last record today isCLOCK_IN, the next isCLOCK_OUT, and vice-versa (logic in attendanceattendance.service.ts).submitType: QRdistinguishes it fromDEVICE/MANUAL.
5. Permissions
- No RBAC /
permission_groupsinvolvement. ESS access is gated solely by a valid ESS JWT withrole: 'EMPLOYEE'. EssAuthorizeGuard(src/core/guards/ess-authorize.guard.ts) is the intended gate: it requiresBearer <token>, verifies it withjwt_access_token_secret, rejects ifrole !== 'EMPLOYEE'('ESS access only') or ifemployeeId/companyIdclaims are missing, thencontextSvc.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 (theJwtStrategy.validatesimply 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.tsmutationMap).
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/sessionto hydrate from cookie. signIn(employeeRef, pin)→essSignInmutation →POST /api/ess/session→setSession.signOut()→DELETE /api/ess/session→ clear.useEssAuthState()is the only consumer;useEssAuthQuery()wrapsessSignIn/essRefreshToken.
profile/context.tsx (EssProfileContextProvider) wraps essMe and exposes useEssProfileState().
7.4 Layout (layout/*)
EssLayout— wraps children in<ApolloProvider client={essApolloClient}>+EssAuthContextProvider;max-w-mdmobile shell; sets PWA<meta>+manifest; onwindow '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 ifisManagerelse Payroll.
7.5 Utils (utils/*)
ess-guard.ts—requireEssSession(ctx): SSR guard; redirects to/ess/loginif noess-tokencookie. Used in each protected ESS page'sgetServerSideProps.dashboard-quicklinks.tsx— the 9 quick links (Leave, Time, Claims, Payroll, Training, Advance, Loan, Timesheet, Calendar) + date formatters + approvalKIND_LABEL/KIND_CLASS/STATUS_CLASS/HREFmaps.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.clockInfor 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 viacontextSvc.employeeIdinjected 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
- ESS tokens share the access-token secret. Because
essSignInsigns the access token withjwt_access_token_secret, the standardGqlAuthGuardaccepts 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()onessSetPin.) - Per-employee filtering is implicit in repositories, not the API. There are no
myLeave/myClaimoperations — the same resolver serves admin and employee, andbuildQueryinjectsemployeeIdonly when an ESS token is present. Forget thebuildQuerychange in a new HR repo and an ESS user would see everyone's rows. This is the single most important rebuild rule. signInlookup is byrefalone, company-unscoped. Current code callsfindOneForAuth({ ref })with nocompanyId(the operation is public, so no company in context).refmust therefore be unique enough to resolve across tenants. (The original plan scoped bycontextSvc.companyId— diverged.)isManagerdepends onreportingTo, which is not user-editable. SincereportingTois absent from the employee create/update form (set only by migration — see employee §9), most employees getisManager: falseand never see the Approvals tab unlessreportingTowas populated externally.- QR token carries no identity; the scanner's token does. The 25-second QR proves freshness only;
qrClockInrecords attendance forcontextSvc.employeeId(the scanning employee). A QR cannot be used to clock in someone else. ess-tokencookie ishttpOnly: false— deliberately readable by the ApolloauthLinkin the browser. The cookie'smaxAge(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).- Offline outbox covers only leave/advance/claim. Other submissions (timesheet, loan) are online-only; they are not queued by
outbox.ts. - PIN is
select: falseeverywhere exceptfindOneForAuth. Don't expect it on a normal employee query, and never return it. - Branding is hardcoded to "Mabiz ESS" in the layout/manifest/login (
title,name, theme color#014473). Re-skin per tenant if reused.