Saltar al contenido principal

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_REPOSITORY Symbol defined in domain layer
  • Service injects via @Inject(EMPLOYEES_REPOSITORY) with IEmployeesRepository interface 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

SectionFields
Identityid, businessId, locationId, userId
IdentificationemployeeNumber, fullName, email, phone
POS Identityemployee_code (integer, auto-assigned ≥ 1000), must_change_pin (boolean)
Job Infoposition, department, employmentType, hireDate, terminationDate
ApprovalapprovalPinHash, canApproveReturns/Discounts/Voids, canOverridePrices
CompensationcommissionRate, hourlyRate, salaryAmount, tipPoolShare
RestaurantserverNumber, canTakeOrders, canCloseTables, maxTableCapacity
RetailsalesTargetMonthly, canProcessReturns
StatusisActive, isManager, notes
AuditcreatedAt, 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 NULL PARTIAL 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-pin returns { valid: false } (no manager approvals until rotation).
  • POST /employees/select-by-code still 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

MethodEndpointDescription
POST/employeesCreate 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/:idUpdate (businessId + updatedBy in body)
DELETE/employees/:id?businessId=<id>Soft delete (sets isActive=false)

Lookups

MethodEndpointDescription
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

MethodEndpointDescription
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

MethodEndpointDescription
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

ParamTypeDefaultDescription
sizenumber10Page size
pagenumber1Page number
orderBystringfullNameSort field: fullName, hireDate, position, employeeNumber
orderstringascSort direction: asc, desc
searchstringSearch across fullName, email, employeeNumber
isActivebooleanFilter by active status
positionstringFilter by position
departmentstringFilter by department
locationIdUUIDFilter by location
isManagerbooleanFilter 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)

  1. Open Employees (/forms/EmployeePage).
  2. Create or edit an employee record.
  3. In System account, pick the business user to link, or use Link my account to pre-fill your own user, name, and email.
  4. 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).
  • canApproveDiscounts and 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/)

FileRole
NumericPinKeypad.tsxTouch-friendly 3×4 keypad (computer numpad order: 7–8–9 top row)
useEmployeeCodePinFlow.tsCode → PIN → forced PIN rotation state machine
EmployeeCodePinPanel.tsxReusable panel (display + keypad + actions)
EmployeePinStatusBadge.tsxPIN 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 operator
    • flowpos: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 via PosOperatorOverflowSection

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: EmployeeForm passes employee={initialData} and onPinChanged into EmployeeBasicInfoSection so POS code and PIN management appear when editing
  • List: employeeCode and EmployeePinStatusBadge columns 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, default canApproveReturns)
  • Desktop: existing EmployeeSelect + text PIN field

Returns and exchanges pass requiredPermission="canApproveReturns".

Consumers updated for resolved employee

ConsumerChange
useRestaurantShiftcashierEmployeeId from posOperator.resolvedEmployee
CreateOrderSectionDefault waiterId from posOperator.resolvedEmployee

Rollout checklist

  1. Run migration 2026-06-19t10-00-00-add-employee-code.mjs in each environment
  2. Run packages/backend/scripts/src/backfill-employee-pins.ts if existing employees need codes/PINs
  3. Train staff: share POS codes, set PINs from Employees screen, use operator chip on shared terminals
  4. Verify returns/exchanges manager approval on both touch POS and desktop

Integration with Other Modules

The EmployeesService is exported and used by:

ModuleUsage
Restaurant Ordersorder.waiter_id → validates active + canTakeOrders
Retail Salessale.salesperson_id, sale.approved_by_employee_id
Cash Registersession.opened_by_employee_id, session.closed_by_employee_id
DiscountsEmployee approval for discount applications
Store CreditEmployee context resolution
Data ImportBulk 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 linked
  • findByEmployeeNumber(businessId, number) — throws NotFoundException
  • findAll(businessId, options) — legacy paginated list
  • create(businessId, userId, data) — legacy create
  • update(id, userId, data) — legacy update

Security

PIN Management

  • Algorithm: bcrypt (cost factor 12)
  • Storage: Only hashed in approvalPinHash column
  • Format: 4-6 digits (validated via regex)
  • Logging: All operations logged (raw PINs never logged)

Permission Flags

FlagPurpose
canApproveReturnsApprove return requests
canApproveDiscountsApprove discount applications
canApproveVoidsApprove void transactions
canOverridePricesOverride item prices
canTakeOrdersTake restaurant orders
canCloseTablesClose restaurant tables
canProcessReturnsProcess 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 NULL to preserve transaction history

Design Decisions

  1. Soft deletes only — employees are never physically deleted to preserve transaction attribution
  2. Symbol-based DI — ensures proper hexagonal boundaries between layers
  3. Backward-compatible methods — external consumers use simpler signatures while the controller uses the standard pattern
  4. Business-scoped repository — all mutations require businessId for multi-tenancy safety

Version: 2.0.0 Last Updated: 2026-03-24 Status: Production Ready