Employee Module Documentation
Overview
The Employee module manages business staff members, separating "system user" (Firebase authentication) from "employee" (business entity). This enables transaction attribution, commission tracking, PIN-based manager approvals, and role-specific permissions for staff who may or may not have system access.
Architecture
Hexagonal Structure
apps/backend/src/employees/
├── employees.module.ts # Module registration
├── domain/
│ └── employees-repository.domain.ts # Repository interface (port) + EMPLOYEES_REPOSITORY token
├── application/
│ └── employees.service.ts # Business logic & use cases
├── infrastructure/
│ └── employees.repository.ts # Kysely implementation (adapter)
└── interfaces/
├── employees.controller.ts # REST endpoints (adapter)
├── dtos/
│ ├── create-employee.dto.ts # Create validation + Swagger schema
│ ├── update-employee.dto.ts # Update validation + Swagger schema
│ ├── set-approval-pin.dto.ts # PIN set validation
│ └── verify-pin.dto.ts # PIN verify validation
└── query/
└── paginate-employees.query.ts # Pagination/filter/sort query (OffsetPaginationMixin + SortMixin)
Dependency Injection
The module uses Symbol-based DI tokens for proper hexagonal boundaries:
EMPLOYEES_REPOSITORYSymbol defined in domain layer- Service injects via
@Inject(EMPLOYEES_REPOSITORY)withIEmployeesRepositoryinterface type - Module registers:
{ provide: EMPLOYEES_REPOSITORY, useClass: EmployeesRepository }
Guards & Permissions
@UseGuards(RolesGuard)— enforces RBAC@PermissionResource(PolicyResource.Employee)— class-level resource binding@PermissionAction(PolicyAction.Create/Read/Update/Delete)— per-endpoint action
Database Schema
Table: employee
| Section | Fields |
|---|---|
| Identity | id, businessId, locationId, userId |
| Identification | employeeNumber, fullName, email, phone |
| POS Identity | employee_code (integer, auto-assigned ≥ 1000), must_change_pin (boolean) |
| Job Info | position, department, employmentType, hireDate, terminationDate |
| Approval | approvalPinHash, canApproveReturns/Discounts/Voids, canOverridePrices |
| Compensation | commissionRate, hourlyRate, salaryAmount, tipPoolShare |
| Restaurant | serverNumber, canTakeOrders, canCloseTables, maxTableCapacity |
| Retail | salesTargetMonthly, canProcessReturns |
| Status | isActive, isManager, notes |
| Audit | createdAt, createdBy, updatedAt, updatedBy |
Key Indexes
(businessId)— business scoping(businessId, isActive)— active employee queries(businessId, position)— position filtering(businessId, employeeNumber)UNIQUE — employee number per business(businessId, userId)UNIQUE — one employee per user per business(businessId, employee_code) WHERE employee_code IS NOT NULLPARTIAL UNIQUE — code uniqueness per business
employee_code and must_change_pin
employee_code is an auto-assigned, immutable, sequential integer (≥ 1000) unique per business. It is assigned at creation time by MAX(employee_code) + 1, falling back to 1000 for the first employee. The column cannot be changed via PATCH /employees/:id.
must_change_pin starts true for every new employee. It is cleared to false only when:
- The employee rotates their PIN via
POST /employees/:id/set-pin.
While must_change_pin = true:
POST /employees/:id/verify-pinreturns{ valid: false }(no manager approvals until rotation).POST /employees/select-by-codestill succeeds — the employee can log in and be prompted to rotate.
Existing employees (backfill): The migration 2026-06-19t10-00-00-add-employee-code.mjs backfills sequential codes from 1000. It sets must_change_pin = false for employees who already had an approval_pin_hash. For employees with NULL hash, run the one-time backfill script to seed the hash from the code:
tsx packages/backend/scripts/src/backfill-employee-pins.ts [--dry-run] [--business-id=<uuid>]
API Endpoints
All endpoints require Bearer authentication and businessId context.
CRUD
| Method | Endpoint | Description |
|---|---|---|
POST | /employees | Create employee (businessId + createdBy in body) |
GET | /employees?businessId=<id> | Paginated list with filters and sorting |
GET | /employees/:id?businessId=<id> | Get by ID |
PATCH | /employees/:id | Update (businessId + updatedBy in body) |
DELETE | /employees/:id?businessId=<id> | Soft delete (sets isActive=false) |
Lookups
| Method | Endpoint | Description |
|---|---|---|
GET | /employees/active?businessId=<id> | All active employees |
GET | /employees/managers?businessId=<id> | All active managers |
GET | /employees/by-number/:number?businessId=<id> | Find by employee number |
POS Operator Selection
| Method | Endpoint | Description |
|---|---|---|
POST | /employees/select-by-code?businessId=<id> | Identify + authenticate active employee by code + PIN |
Request body: { employeeCode: number, pin: string }
Response: { employee: Employee (approvalPinHash stripped), mustChangePin: boolean }
Errors:
401— invalid code or PIN (no distinction to prevent existence leaks).429— rate limit exceeded (5 attempts per 15 minutes per IP). The PWA should surface a lockout message.
Security note — mustChangePin: A new employee's default PIN equals their employee_code. While mustChangePin = true the employee can select themselves at the POS (to log in and rotate), but POST /employees/:id/verify-pin returns valid: false, blocking all manager-approval workflows (returns, discounts, voids, price overrides) until the PIN is rotated.
⚠️ Residual risk: Because the default PIN equals the code (both visible to an observer), a bystander who sees the code could rotate the PIN first ("first-to-rotate wins"). Mitigations: approvals are blocked until rotation, inactive employees are excluded from lookup, and the PWA forces rotation on first login. Operators should rotate employee PINs promptly at rollout.
PIN Management
| Method | Endpoint | Description |
|---|---|---|
POST | /employees/:id/set-pin?businessId=<id> | Set 4-6 digit approval PIN (clears mustChangePin) |
POST | /employees/:id/verify-pin?businessId=<id> | Verify PIN → { valid: boolean } (rate-limited: 5/15 min) |
DELETE | /employees/:id/pin?businessId=<id> | Remove PIN (sets mustChangePin = true) |
Pagination Parameters
| Param | Type | Default | Description |
|---|---|---|---|
size | number | 10 | Page size |
page | number | 1 | Page number |
orderBy | string | fullName | Sort field: fullName, hireDate, position, employeeNumber |
order | string | asc | Sort direction: asc, desc |
search | string | — | Search across fullName, email, employeeNumber |
isActive | boolean | — | Filter by active status |
position | string | — | Filter by position |
department | string | — | Filter by department |
locationId | UUID | — | Filter by location |
isManager | boolean | — | Filter by manager flag |
Usage Examples
Creating an Employee
// Via service (internal)
const employee = await employeesService.createEmployee({
businessId,
fullName: "John Doe",
employeeNumber: "EMP-001",
position: "Server",
canTakeOrders: true,
isActive: true,
createdBy: userId,
});
// Via API
POST /employees
{
"businessId": "...",
"fullName": "John Doe",
"employeeNumber": "EMP-001",
"position": "Server",
"canTakeOrders": true,
"createdBy": "..."
}
Setting Approval PIN
await employeesService.setApprovalPin(employeeId, businessId, userId, "1234");
const isValid = await employeesService.verifyPin(employeeId, businessId, "1234");
Linking system users
POS screens (sales discounts, restaurant shifts, cash register sessions) resolve the current employee by matching the logged-in user's Postgres user.id to employee.userId within the active business. Without that link, discount controls and shift actions are hidden.
In the PWA (Employees screen)
- Open Employees (
/forms/EmployeePage). - Create or edit an employee record.
- In System account, pick the business user to link, or use Link my account to pre-fill your own user, name, and email.
- Save. The unique constraint
(businessId, userId)allows only one employee per user per business.
If your account is not linked, an amber notice appears on the Employees list with Link my account (opens the create form with your user pre-selected).
From Users (/forms/UsersPage), rows without a linked employee show Create employee, which opens the employee create form with that user pre-selected.
Bulk import (userEmail)
For many employees, use Data Imports → Employee (/imports/employee) and map the optional userEmail column. The import handler resolves the email to an existing business user and sets userId on upsert (by employeeNumber or employee email). See the Data Import user guide.
Requirements:
- The user must already exist and belong to the business (invite them first).
- One employee per user per business (duplicate links fail with a clear API error).
canApproveDiscountsand other permission flags are separate from linking — linking only enables attribution; managers still need the right flags for approvals.
PWA integration (employee code + PIN)
Shared POS terminals use employee code + PIN for operator sign-in, separate from the Firebase-linked employee used on desktop admin screens.
Shared components (apps/frontend-pwa/src/components/employee-pin/)
| File | Role |
|---|---|
NumericPinKeypad.tsx | Touch-friendly 3×4 keypad (computer numpad order: 7–8–9 top row) |
useEmployeeCodePinFlow.ts | Code → PIN → forced PIN rotation state machine |
EmployeeCodePinPanel.tsx | Reusable panel (display + keypad + actions) |
EmployeePinStatusBadge.tsx | PIN set / not set / change required badges |
EmployeeCodeLoginModal is a thin wrapper around the panel for operator selection.
POS operator session (PosOperatorContext)
- Storage keys (sessionStorage, per
businessId+locationId):flowpos:pos-operator:v1:{businessId}:{locationId}— explicit code+PIN operatorflowpos:pos-operator-dismissed:v1:{businessId}:{locationId}— user ended operator session on touch POS
resolvePosEmployeeAttribution({ operator, linked, blockLinkedFallback })— operator wins; linked fallback blocked when dismissed on touch-POS routes- End operator session clears the explicit operator and sets dismissed; it does not call Firebase logout (header Logout is separate)
- After dismiss on touch POS, attribution requires code+PIN again (linked employee is not used until sign-in)
- Soft prompt opens the login modal once per scope on first touch-POS visit when no attribution exists; it does not re-open after an explicit end session
- Header chip:
PosOperatorChip(Sign in / Switch / End operator session) on desktop toolbar; mobile overflow viaPosOperatorOverflowSection
Mounted in AuthenticatedLayout via PosOperatorProvider.
Known limitation: ending operator session does not close an open restaurant shift; cashier attribution on that shift may still reference the prior employee until the shift is closed manually.
Admin UI (Employees screen)
- Edit form:
EmployeeFormpassesemployee={initialData}andonPinChangedintoEmployeeBasicInfoSectionso POS code and PIN management appear when editing - List:
employeeCodeandEmployeePinStatusBadgecolumns on table and mobile cards - Create toast: mentions assigned POS code and prompts admin to set PIN
Manager approval (hybrid)
ManagerApprovalModal uses variant: "auto" (default):
- Touch-POS routes:
EmployeeCodePinPanel+ permission gate (requiredPermission, defaultcanApproveReturns) - Desktop: existing
EmployeeSelect+ text PIN field
Returns and exchanges pass requiredPermission="canApproveReturns".
Consumers updated for resolved employee
| Consumer | Change |
|---|---|
useRestaurantShift | cashierEmployeeId from posOperator.resolvedEmployee |
CreateOrderSection | Default waiterId from posOperator.resolvedEmployee |
Rollout checklist
- Run migration
2026-06-19t10-00-00-add-employee-code.mjsin each environment - Run
packages/backend/scripts/src/backfill-employee-pins.tsif existing employees need codes/PINs - Train staff: share POS codes, set PINs from Employees screen, use operator chip on shared terminals
- Verify returns/exchanges manager approval on both touch POS and desktop
Integration with Other Modules
The EmployeesService is exported and used by:
| Module | Usage |
|---|---|
| Restaurant Orders | order.waiter_id → validates active + canTakeOrders |
| Retail Sales | sale.salesperson_id, sale.approved_by_employee_id |
| Cash Register | session.opened_by_employee_id, session.closed_by_employee_id |
| Discounts | Employee approval for discount applications |
| Store Credit | Employee context resolution |
| Data Import | Bulk employee upsert |
Backward-Compatible Methods
For cross-module consumers, the service exposes convenience methods with simpler signatures:
findById(id)— throws NotFoundException (no businessId required)findByUserId(businessId, userId)— returns null if not linkedfindByEmployeeNumber(businessId, number)— throws NotFoundExceptionfindAll(businessId, options)— legacy paginated listcreate(businessId, userId, data)— legacy createupdate(id, userId, data)— legacy update
Security
PIN Management
- Algorithm: bcrypt (cost factor 12)
- Storage: Only hashed in
approvalPinHashcolumn - Format: 4-6 digits (validated via regex)
- Logging: All operations logged (raw PINs never logged)
Permission Flags
| Flag | Purpose |
|---|---|
canApproveReturns | Approve return requests |
canApproveDiscounts | Approve discount applications |
canApproveVoids | Approve void transactions |
canOverridePrices | Override item prices |
canTakeOrders | Take restaurant orders |
canCloseTables | Close restaurant tables |
canProcessReturns | Process retail returns |
Multi-Tenancy
- All queries scoped by
businessId - Unique constraints per-business (employeeNumber, userId)
- One employee record per user per business
- Foreign keys use
ON DELETE SET NULLto preserve transaction history
Design Decisions
- Soft deletes only — employees are never physically deleted to preserve transaction attribution
- Symbol-based DI — ensures proper hexagonal boundaries between layers
- Backward-compatible methods — external consumers use simpler signatures while the controller uses the standard pattern
- Business-scoped repository — all mutations require businessId for multi-tenancy safety
Version: 2.0.0 Last Updated: 2026-03-24 Status: Production Ready