npm package: @markwharton/eh-hr
Description: Employment Hero HR API client
Author: Mark Wharton
Latest version: 3.1.0

# @markwharton/eh-hr

Employment Hero HR API client for employees, teams, and leave requests.

## Install

```bash
npm install @markwharton/eh-hr
```

## Quick Start

```typescript
import { EHClient, getAccessToken } from '@markwharton/eh-hr';

// Step 1: Obtain an access token via OAuth2
const tokenResult = await getAccessToken({
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
  refreshToken: 'your-refresh-token',
});
if (!tokenResult.ok) throw new Error(tokenResult.error);

// Step 2: Persist rotated refresh token if returned
if (tokenResult.data!.refreshToken) {
  await saveRefreshToken(tokenResult.data!.refreshToken);
}

// Step 3: Create the client with the access token
const client = new EHClient({
  accessToken: tokenResult.data!.accessToken,
  organisationId: '123',
  cache: {},   // enable caching with defaults
  retry: {},   // enable retry with defaults
});

// Step 4: Validate the token works
const validation = await client.validateToken();
if (!validation.ok) throw new Error(validation.error);

// Get all employees (auto-paginates)
const empResult = await client.getEmployees();
if (empResult.ok) console.log(empResult.data); // EHEmployee[]

// Get a single employee by ID
const singleResult = await client.getEmployee('emp-123');
if (singleResult.ok) console.log(singleResult.data); // EHEmployee

// Filter by status (client-side, case-insensitive)
const active = await client.getEmployees({ status: 'active' });

// Get all teams
const teamResult = await client.getTeams();
if (teamResult.ok) console.log(teamResult.data); // EHTeam[]

// Get leave requests for a date range (server-side filtering)
const leaveResult = await client.getLeaveRequests({
  startDate: '2026-01-01',
  endDate: '2026-03-31',
  employeeId: '150',  // client-side filter
});
if (leaveResult.ok) console.log(leaveResult.data); // EHLeaveRequest[]
```

## Result Pattern

All methods return `Result<T>` — see [api-core Result Pattern](../../README.md#result-pattern). Always check `ok` before accessing `data`.

## Authentication

### OAuth2 Refresh Token Grant

The HR API uses OAuth2 Bearer token authentication. The Quick Start above shows the full flow: `getAccessToken()` → persist rotated token → create client → validate.

Under the hood, `getAccessToken()` sends:

```
POST https://oauth.employmenthero.com/oauth2/token
Content-Type: application/x-www-form-urlencoded

client_id=xxx&client_secret=yyy&grant_type=refresh_token&refresh_token=zzz
```

The response is mapped from snake_case to camelCase:

| API field | `EHTokenResponse` field | Description |
|-----------|------------------------|-------------|
| `access_token` | `accessToken` | Token for API requests |
| `refresh_token` | `refreshToken` | New refresh token (if rotated) |
| `token_type` | `tokenType` | Typically `'Bearer'` |
| `expires_in` | `expiresIn` | Lifetime in seconds |
| `scope` | `scope` | OAuth2 scope |

**Refresh token rotation:** The auth server may return a new refresh token. If `refreshToken` is present in the response, persist it — the old one may be invalidated. The library does not handle token storage, rotation persistence, or expiry tracking; that is the consumer's responsibility.

**Custom auth URL:** Pass a second argument to override the default auth endpoint:

```typescript
const tokenResult = await getAccessToken(credentials, 'https://custom-auth.example.com/token');
```

### Validating the Token

The HR API has no dedicated auth-check endpoint. `validateToken()` verifies the access token by requesting a single employee record (`page_index=1`, `item_per_page=1`). Returns 401 or 403 for invalid/expired tokens.

## API Reference

| Method | Parameters | Returns |
|--------|-----------|---------|
| `validateToken()` | — | `Result<void>` |
| `getEmployees(options?)` | `EHEmployeeOptions?` | `Result<EHEmployee[]>` |
| `getEmployee(employeeId)` | `string` | `Result<EHEmployee>` |
| `getTeams()` | — | `Result<EHTeam[]>` |
| `getLeaveRequests(options?)` | `EHLeaveRequestOptions?` | `Result<EHLeaveRequest[]>` |
| `clearCache()` | — | `void` |
| `invalidateEmployeeCache()` | — | `void` |
| `invalidateLeaveRequestCache()` | — | `void` |
| `invalidateTeamCache()` | — | `void` |

### Options

**`EHEmployeeOptions`:**

| Option | Type | Description |
|--------|------|-------------|
| `memberType` | `'employee' \| 'contractor'` | Filter by member type (server-side) |
| `employeeId` | `string` | Filter by employee code or ID (client-side, matches `code` or `id`) |
| `status` | `string` | Filter by status, e.g. `'active'` or `'inactive'` (client-side, case-insensitive) |
| `pageSize` | `number` | Internal batch size for auto-pagination (default: 100) |

**`EHLeaveRequestOptions`:**

| Option | Type | Description |
|--------|------|-------------|
| `startDate` | `string` | Filter by start date, `YYYY-MM-DD` (server-side) |
| `endDate` | `string` | Filter by end date, `YYYY-MM-DD` (server-side) |
| `employeeId` | `string` | Filter by employee code or UUID (client-side, resolves code → UUID via `getEmployees`) |
| `status` | `string` | Filter by status, e.g. `'approved'`, `'pending'` (client-side, case-insensitive) |
| `pageSize` | `number` | Internal batch size for auto-pagination (default: 100) |

### Auto-Pagination

All data methods automatically paginate through all results using the HR API's `page_index`/`item_per_page` parameters. The consumer always receives the complete array.

The `pageSize` option controls the internal batch size (items per API call, default 100):

```typescript
// Fetch employees in smaller batches (50 per API call)
const result = await client.getEmployees({ pageSize: 50 });
```

### Server-Side and Client-Side Filtering

Some filters are applied server-side (reducing data transferred), while others are applied client-side after cache retrieval:

- **Server-side:** `memberType` (employees), `startDate`/`endDate` (leave requests) — passed as query parameters to the API. Different filter values produce separate cache entries.
- **Client-side:** `employeeId`, `status` — applied after fetching. All filter combinations for the same server-side query share a cache entry.

**Leave requests are time-series data** — unlike employees and teams which remain relatively stable, leave requests grow continuously as new requests accumulate. Always use `startDate`/`endDate` filters to bound the result set. Without date filters, the API returns the entire leave request history, which increases over time and may be significant in memory-constrained environments (e.g., serverless functions).

## Configuration

Provide either `accessToken` (manual token lifecycle) or `credentials` (auto-managed). Not both.

### Manual Token Mode

```typescript
const client = new EHClient({
  accessToken: 'xxx',             // OAuth2 access token (manual lifecycle)
  organisationId: '123',
  cache: {},
  retry: {},
});
```

### Credentials Mode (Auto-Managed)

```typescript
const client = new EHClient({
  credentials: {                  // OAuth2 credentials (auto-managed lifecycle)
    clientId: 'xxx',
    clientSecret: 'yyy',
    refreshToken: 'zzz',
  },
  organisationId: '123',
  onTokenRefresh: async (newRefreshToken) => {
    await saveRefreshToken(newRefreshToken);  // persist rotated token
  },
  cache: {},
  retry: {},
});
```

In credentials mode, the client automatically:
- **Lazily acquires** the token on first API call (not at construction)
- **Caches** the token and reuses it within its TTL
- **Proactively refreshes** at 80% of TTL (e.g., 48 min for a 60 min token)
- **Coalesces** concurrent refresh requests (only one auth call)
- **Rotates refresh tokens** — updates internally and calls `onTokenRefresh` so you can persist

### All Options

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `accessToken` | `string` | — | OAuth2 access token (manual mode) |
| `credentials` | `EHOAuthCredentials` | — | OAuth2 credentials (credentials mode) |
| `organisationId` | `string` | **required** | Organisation ID |
| `authUrl` | `string` | EH production auth URL | Override OAuth2 token endpoint |
| `onTokenRefresh` | `(token: string) => void` | — | Callback when refresh token is rotated |
| `baseUrl` | `string` | EH HR API URL | Override API base URL |
| `onRequest` | `callback` | — | Debug callback for each API request |
| `rateLimitPerSecond` | `number` | `5` | Client-side rate limiting (0 to disable) |
| `cache` | `EHCacheConfig` | disabled | Enable caching with optional TTL overrides |
| `retry` | `RetryConfig` | disabled | Enable retry for 429/503 with exponential backoff |

`EHConfig` extends `ClientConfig` from api-core, which provides the `baseUrl`, `onRequest`, `retry`, and `cacheInstance` fields. Pass `cacheInstance` to use a custom cache backend (e.g., `LayeredCache` with persistent stores); otherwise `cache: {}` creates an in-memory `TTLCache`.

### Cache TTLs

| Cache Key | Default TTL |
|-----------|-------------|
| Employees | 5 min |
| Teams | 5 min |
| Leave requests | 2 min |

Failed API results (`ok: false`) are never cached — transient errors won't persist for the full TTL. See the [root README Cache System section](../../README.md#cache-system) for the full cache architecture (layered stores, restricted data handling, request coalescing).

### Rate Limiting

The client enforces a sliding-window rate limit of 5 requests per second. All outbound requests pass through the rate limiter automatically. Set `rateLimitPerSecond: 0` in config to disable (useful for tests). The `RateLimiter` class is provided by api-core and operates per-instance.

### Retry

Automatically retries on HTTP 429 (Too Many Requests) and 503 (Service Unavailable) with exponential backoff. Respects the `Retry-After` header when present.

## Utilities

| Export | Description |
|--------|-------------|
| `getAccessToken(credentials, authUrl?)` | OAuth2 refresh token grant helper |
| `pickFields<T>(obj, spec)` | Select fields from API responses (re-exported from api-core) |
| `RateLimiter` | Sliding window rate limiter class (re-exported from api-core) |

## Error Handling

| Export | Description |
|--------|-------------|
| `HRError` | Custom error class extending `ApiError`, with `status`, static `fromResponse()` |
| `parseHRErrorResponse` | Parse EH HR error JSON to `HRParsedError` |

## License

MIT