-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhris.ts
More file actions
300 lines (279 loc) · 14.4 KB
/
Copy pathhris.ts
File metadata and controls
300 lines (279 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
import { z } from "zod";
import recordedReport from "@/db/fixtures/bamboohr/report.json";
import { nonNull } from "@/lib/assert";
import { env } from "@/lib/env";
import { Employee, OrgChart, type OrgIssue } from "@/lib/schema";
/**
* HRIS adapter, the onboarding side's integration seam.
*
* The onboarding discovery agent needs one thing from a client's HR system: the
* org, normalised. Who works here, their title/department, and who they report
* to. From that the agent derives an approval matrix (no HRIS stores "approval
* authority" natively). Everything vendor-specific stops at this file, the agent
* imports `HrisAdapter` and `OrgChart`, never a BambooHR field name. Swap
* `bambooHris` for a `workdayHris` implementing the same interface and nothing
* downstream changes. This is the exact mirror of the ERP seam in `erp.ts`.
*
* Two implementations, both PURE (no env reads, no knowledge of each other):
* • `bambooHris(creds)` , live HTTP against the real BambooHR API, scoped to the
* demo division.
* • `recordedHris()` , replays a fixture from disk through the SAME mapper and
* the SAME division scope.
*
* The recorded fixture (`db/fixtures/bamboohr/report.json`) is BUILT from the seed
* definition (`scripts/build-recorded-fixture.ts` renders `SEED_ORG` into BambooHR's
* report shape), NOT a live capture. So recorded and live read the exact same demo
* org: the same ~13 people, the same planted data-quality issues, the same shape,
* the same mapper. That's what lets the demo run without a trial key (and CI with no
* key at all) while staying honest, the fixture's `_meta` says it's seed-built, not
* captured. (`scripts/capture-bamboo.ts` still exists to snapshot a REAL scoped
* response if you have a key, but the committed fixture is the seed-built one so it
* never drifts from the seed.)
*
* @public
*/
export type HrisAdapter = {
readonly name: string;
/** Fetch the normalised org. Throws only on transport/parse failure. */
fetchOrg(): Promise<OrgChart>;
};
/* ────────────────────────────────────────────────────────────────────────── *
* The vendor's wire shape (BambooHR), confined to this file
* ────────────────────────────────────────────────────────────────────────── */
/**
* What BambooHR's `POST /reports/custom` returns for the fields we request, one
* call yields the whole org with ID-based reporting edges. Validated with Zod (the
* payload is parsed JSON) so the mapper reads it without an `as` cast. BambooHR
* returns numbers as strings and empty values as `null` (not absent), so every
* field is nullish; the mapper reads them defensively (`?? ""`, optional chaining).
* Unknown extra fields are ignored.
*/
const nullishStr = z.string().nullish();
const BambooReportRow = z.object({
id: nullishStr,
firstName: nullishStr,
lastName: nullishStr,
displayName: nullishStr,
jobTitle: nullishStr,
department: nullishStr,
division: nullishStr,
supervisorEId: nullishStr,
supervisorEmail: nullishStr,
/** "Active" | "Inactive", terminated staff would pollute the hierarchy. */
status: nullishStr,
});
const BambooReport = z.object({
employees: z.array(BambooReportRow).optional(),
});
/** The exact fields the report request asks BambooHR for. */
const BAMBOO_REPORT_FIELDS = [
"id",
"firstName",
"lastName",
"displayName",
"jobTitle",
"department",
"division",
"supervisorEId",
"supervisorEmail",
"status",
] as const;
/* ────────────────────────────────────────────────────────────────────────── *
* The shared mapper, vendor shape → internal OrgChart
* ────────────────────────────────────────────────────────────────────────── */
/**
* Turn a raw BambooHR custom-report payload into our `OrgChart`. This is the ONE
* place BambooHR's shape becomes our shape; it runs identically on live bytes and
* on replayed fixture bytes, so the two adapters can't drift. Pure and
* synchronous, easy to test against the captured fixture.
*
* Two real-world cleanups happen here, both deliberate:
* 1. Drop non-active rows. The report returns terminated employees (status !=
* "Active"); including them would invent phantom managers and dead branches.
* 2. Resolve reporting edges by ID and FLAG what doesn't resolve, rather than
* guessing. A `supervisorEId` pointing at an id that isn't in the active set
* (a dangling edge), a self-reference, or a non-root with no manager all
* become `OrgIssue`s for a human, the forward-deployed-engineer's actual
* onboarding work, made explicit.
*/
export const mapBambooReport = (raw: unknown, source: string, division?: string): OrgChart => {
// `raw` is unknown (a parsed JSON payload), validate with Zod so we read
// `employees` without a cast (a malformed payload yields no rows, not a throw).
const parsed = BambooReport.safeParse(raw);
const rows = parsed.success ? (parsed.data.employees ?? []) : [];
// 1. Keep active rows that have an id; normalise each field. When a division
// scope is given, keep only that division, this is how one client's org is
// isolated from the rest of the sandbox.
const active = rows.filter(
(r) =>
(r.status ?? "Active") === "Active" &&
r.id &&
// eslint-disable-next-line custom/no-empty-string-fallback -- comparing a possibly-absent division against the scope; "" (no division) simply never equals a real scope name.
(division === undefined || (r.division ?? "") === division)
);
const employees = active.map((r) => {
const name =
(r.displayName && r.displayName.trim()) ||
[r.firstName, r.lastName].filter(Boolean).join(" ").trim() ||
`Employee ${r.id}`;
const managerId = r.supervisorEId && r.supervisorEId !== "0" ? r.supervisorEId : null;
return Employee.parse({
// `active` was filtered on `r.id` being present, so it's a string here.
id: nonNull(r.id, "active row has an id (filtered above)"),
name,
// eslint-disable-next-line custom/no-empty-string-fallback -- "" is the intended "no title" value written into the Employee schema (blank title is valid).
title: r.jobTitle?.trim() ?? "",
// eslint-disable-next-line custom/no-empty-string-fallback -- "" is the intended "no department" value for the Employee schema (unassigned is valid).
department: r.department?.trim() ?? "",
// eslint-disable-next-line custom/no-empty-string-fallback -- "" is the intended "no division" value for the Employee schema (unscoped is valid).
division: r.division?.trim() ?? "",
managerId,
});
});
// 2. Resolve edges by id; collect the ones that don't resolve.
const byId = new Map(employees.map((e) => [e.id, e]));
const issues: OrgIssue[] = [];
for (const e of employees) {
if (e.managerId === null) {
// No manager. Fine for the top of the tree; suspicious otherwise. We can't
// know which is the "real" root, so we only flag when there's clearly more
// than one such person (handled after the loop).
continue;
}
if (e.managerId === e.id) {
issues.push({
employeeId: e.id,
employeeName: e.name,
kind: "self-managed",
detail: `${e.name} is listed as their own manager.`,
});
continue;
}
if (!byId.has(e.managerId)) {
issues.push({
employeeId: e.id,
employeeName: e.name,
kind: "dangling-manager",
detail: `${e.name}'s manager id (${e.managerId}) is not an active employee.`,
});
}
}
// Roots = people with no manager. Exactly one is healthy (the CEO). The
// deterministic layer can't know WHICH of several roots is the real top, that
// judgement (by title/seniority) is the agent's job, so when there's more than
// one it surfaces them all for resolution. A blank title on a root is called out
// explicitly: it's the clearest tell of a junk top-level record, as opposed to a
// genuine second executive the agent will have to reason about.
const roots = employees.filter((e) => e.managerId === null);
if (roots.length > 1) {
for (const r of roots) {
const isBlank = r.title.trim() === "";
issues.push({
employeeId: r.id,
employeeName: r.name,
kind: "orphan",
detail: isBlank
? `${r.name} has no manager and no job title, likely a junk top-level record (1 of ${roots.length} roots; an org should have one).`
: `${r.name} (${r.title}) has no manager, 1 of ${roots.length} roots; only the CEO should be at the top, so this needs review.`,
});
}
}
return OrgChart.parse({ source, employees, issues });
};
/* ────────────────────────────────────────────────────────────────────────── *
* Live adapter, real BambooHR
* ────────────────────────────────────────────────────────────────────────── */
/** @public, credentials a live BambooHR adapter needs. */
export type BambooCreds = {
/** Company subdomain: the `neige` in `neige.bamboohr.com`. */
subdomain: string;
/** API key, sent as the Basic-auth username, password is any value. */
key: string;
};
/**
* Live BambooHR adapter. One `POST /reports/custom` returns the whole org with
* ID-based reporting edges (far cheaper than per-employee calls). Auth is HTTP
* Basic with the API key as username and any password (BambooHR's scheme).
*
* @public, the integration seam: swap this for a `workdayHris` implementing
* `HrisAdapter` and nothing downstream changes (cf. `erp.ts`).
*/
export const bambooHris = (creds: BambooCreds, division?: string): HrisAdapter => {
return {
name: division ? `bamboohr (${division})` : "bamboohr",
async fetchOrg() {
const raw = await fetchBambooReport(creds);
return mapBambooReport(raw, "bamboohr", division);
},
};
};
/** The raw HTTP call, exported so the capture script records the exact payload. */
export const fetchBambooReport = async (creds: BambooCreds): Promise<unknown> => {
const auth = Buffer.from(`${creds.key}:x`).toString("base64");
const url = `https://${creds.subdomain}.bamboohr.com/api/v1/reports/custom?format=JSON`;
const res = await fetch(url, {
method: "POST",
headers: {
authorization: `Basic ${auth}`,
accept: "application/json",
"content-type": "application/json",
},
body: JSON.stringify({ title: "orgchart", fields: BAMBOO_REPORT_FIELDS }),
});
if (!res.ok) {
throw new Error(`BambooHR report failed: HTTP ${res.status} ${res.statusText}`);
}
return res.json();
};
/* ────────────────────────────────────────────────────────────────────────── *
* Recorded adapter, replays the captured real payload
* ────────────────────────────────────────────────────────────────────────── */
/**
* Replays the recorded BambooHR payload through the SAME mapper (and the SAME
* division scope) the live adapter uses, so recorded and live read the exact same
* demo org. The fixture is built from the seed definition (see the file's `_meta`
* and scripts/build-recorded-fixture.ts), not a live capture.
*
* The payload is `import`ed as a JSON module, NOT read from disk with
* `readFileSync(process.cwd() + path)`. On Vercel/serverless the bundler only ships
* files it can trace, and a dynamically-built disk path isn't traced, so a
* `readFileSync` fixture is silently absent in prod (the recorded fallback returns
* zero employees). An import is traced and inlined into the function bundle, so it
* works identically local and in prod.
*/
export const recordedHris = (): HrisAdapter => {
return {
name: "bamboohr (recorded)",
// The adapter contract is async (the live one does HTTP); the recorded payload
// is already in memory, so return a resolved promise rather than an async fn.
fetchOrg() {
return Promise.resolve(
mapBambooReport(recordedReport, "bamboohr (recorded)", DEMO_CLIENT_DIVISION)
);
},
};
};
/* ────────────────────────────────────────────────────────────────────────── *
* The single decision point
* ────────────────────────────────────────────────────────────────────────── */
/**
* The division a demo client's org lives under in the shared BambooHR sandbox.
* The live adapter scopes to it so onboarding reads ONE clean client org (the
* seeded tree) instead of the whole account's sample staff. The seed script
* stamps every seeded employee with this division, same constant, one source.
*/
export const DEMO_CLIENT_DIVISION = "LedgerLoop Demo";
/**
* The ONLY place the live-vs-recorded choice is made. Live (scoped to the demo
* client's division) when both creds are present (you, with the trial key);
* recorded, the full captured sample org, otherwise (CI, a teammate, after the
* trial expires). Everything else in the app calls this and is oblivious, that's
* what keeps the fallback from leaking `if (key)` across the codebase.
*
* @public, the entry point the onboarding flow uses to read an org.
*/
export const defaultHris = (): HrisAdapter => {
const key = env.BAMBOO_HR_API_KEY;
const subdomain = env.BAMBOO_HR_SUBDOMAIN;
return key && subdomain ? bambooHris({ key, subdomain }, DEMO_CLIENT_DIVISION) : recordedHris();
};