@kernhq/module-hr 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/contract/index.d.ts +1 -0
  2. package/dist/contract/index.d.ts.map +1 -1
  3. package/dist/contract/index.js +1 -0
  4. package/dist/contract/index.js.map +1 -1
  5. package/dist/contract/models.d.ts +1 -0
  6. package/dist/contract/models.d.ts.map +1 -1
  7. package/dist/contract/models.js +15 -0
  8. package/dist/contract/models.js.map +1 -1
  9. package/dist/contract/permissions.d.ts +8 -0
  10. package/dist/contract/permissions.d.ts.map +1 -1
  11. package/dist/contract/permissions.js +20 -0
  12. package/dist/contract/permissions.js.map +1 -1
  13. package/dist/contract/privacy.d.ts +860 -0
  14. package/dist/contract/privacy.d.ts.map +1 -0
  15. package/dist/contract/privacy.js +412 -0
  16. package/dist/contract/privacy.js.map +1 -0
  17. package/dist/contract/router.d.ts +815 -0
  18. package/dist/contract/router.d.ts.map +1 -1
  19. package/dist/contract/router.js +105 -0
  20. package/dist/contract/router.js.map +1 -1
  21. package/dist/server/router.d.ts +876 -0
  22. package/dist/server/router.d.ts.map +1 -1
  23. package/dist/server/router.js +312 -2
  24. package/dist/server/router.js.map +1 -1
  25. package/dist/server/schema.d.ts +397 -1
  26. package/dist/server/schema.d.ts.map +1 -1
  27. package/dist/server/schema.js +109 -0
  28. package/dist/server/schema.js.map +1 -1
  29. package/dist/server/services/audit.d.ts +249 -0
  30. package/dist/server/services/audit.d.ts.map +1 -0
  31. package/dist/server/services/audit.js +230 -0
  32. package/dist/server/services/audit.js.map +1 -0
  33. package/dist/server/services/people.d.ts +53 -1
  34. package/dist/server/services/people.d.ts.map +1 -1
  35. package/dist/server/services/people.js +74 -1
  36. package/dist/server/services/people.js.map +1 -1
  37. package/dist/server/services/privacy.d.ts +514 -0
  38. package/dist/server/services/privacy.d.ts.map +1 -0
  39. package/dist/server/services/privacy.js +972 -0
  40. package/dist/server/services/privacy.js.map +1 -0
  41. package/migrations/0011_privacy.sql +77 -0
  42. package/migrations/meta/0011_snapshot.json +4450 -0
  43. package/migrations/meta/_journal.json +7 -0
  44. package/package.json +1 -1
  45. package/src/contract/index.ts +1 -0
  46. package/src/contract/models.ts +15 -0
  47. package/src/contract/permissions.ts +22 -0
  48. package/src/contract/privacy.ts +452 -0
  49. package/src/contract/router.ts +121 -0
@@ -0,0 +1,972 @@
1
+ import { KernError } from '@kernhq/kernel';
2
+ import { and, asc, count, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
3
+ import { approvalDecisions, approvalRequests, approvalSteps, attendanceDays, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, officeAssignments, offices, orgUnits, people, peopleSensitive, personDocuments, personHistory, punches, regularizations, retentionSettings, sensitiveAccessLog, } from '../schema.js';
4
+ import { todayIso } from './db.js';
5
+ /**
6
+ * Subject access, erasure and retention.
7
+ *
8
+ * The whole file rests on one decision, and the rest follows from it: **erasure here is redaction,
9
+ * never deletion.** Three of this module's tables say why in their own comments — the ledger is
10
+ * append-only because a balance is the sum of it and nothing else, punches are append-only because
11
+ * an attendance record somebody can quietly rewrite is worth nothing in the dispute it exists for,
12
+ * and `employments` is effective-dated so that March is still answerable in June. Deleting an erased
13
+ * person's rows breaks all three at once, and answers a payroll audit with "she left, so we deleted
14
+ * her file". So every step below clears columns and leaves rows, and the report says which rows
15
+ * survived and on what basis.
16
+ *
17
+ * Two properties are load-bearing and both come out of how the predicates are written:
18
+ *
19
+ * - **Replayable.** Every step matches only rows that still have something to clear, so a second run
20
+ * updates nothing and reports zero. A half-finished erasure is therefore finished by running it
21
+ * again, which matters because the only alternative to a resumable erasure is a refused one.
22
+ * - **Previewable.** A step is a predicate plus a `set`; the dry run counts the predicate and the
23
+ * real run counts it and then applies the set. The preview cannot drift from the thing it previews
24
+ * because there is only one predicate, in one place — the same reason `accrual.preview` runs the
25
+ * code the run runs.
26
+ */
27
+ // =====================================================================================
28
+ // pure helpers — the redaction rules, testable without a database
29
+ // =====================================================================================
30
+ /**
31
+ * The pseudonym an erased person is shown under.
32
+ *
33
+ * `display_name` is `not null` and the contract's `Person.displayName` is `min(1)`, so there is no
34
+ * "no name" to write: something has to go in the column and it must not be a name. The employee
35
+ * number is the right thing when there is one — it is already a pseudonym, it is the join key a
36
+ * payslip carries, and it survives erasure for exactly that reason. Otherwise the front of the row's
37
+ * own uuid, which identifies the record without identifying the person.
38
+ *
39
+ * Not a localised label like "Erased employee": the database is not the place for a language, and a
40
+ * client that knows the row is a tombstone can render one. Knowing that needs `erasedAt` on the
41
+ * `Person` contract, which is a change to `models.ts`, `toPerson` and the client mock together.
42
+ */
43
+ export const erasureDisplayName = (person) => person.employeeNo?.trim() || `person-${person.id.slice(0, 8)}`;
44
+ /**
45
+ * `person_history` fields whose recorded values are personal data.
46
+ *
47
+ * The trap this list exists for: `person_history` stores `from_value`/`to_value` as jsonb for every
48
+ * field, so a redacted `people` row sits beside a history row saying `personalEmail: null → "…"`.
49
+ * Redacting the record and leaving the trail is theatre.
50
+ *
51
+ * What is *not* here is as deliberate. `hiredOn`, `terminatedOn`, `status` and every employment
52
+ * field stay with their values, because those are the employment facts the erasure keeps on `people`
53
+ * and `employments` anyway — clearing their history while keeping the current value would leave the
54
+ * record self-contradictory for no gain. And `sensitive` rows already store only key names, never
55
+ * values (`sensitive.update` has always written it that way), so there is nothing in them to clear.
56
+ */
57
+ export const REDACTED_HISTORY_FIELDS = [
58
+ 'displayName',
59
+ 'workEmail',
60
+ 'personalEmail',
61
+ 'phone',
62
+ 'photoFileId',
63
+ 'timezone',
64
+ 'custom',
65
+ ];
66
+ /** `custom.dietary` is as personal as `custom` itself; the writer records either spelling. */
67
+ export const isRedactableHistoryField = (field) => REDACTED_HISTORY_FIELDS.includes(field) || field.startsWith('custom.');
68
+ /**
69
+ * Replace every occurrence of a person's name inside a JSON value.
70
+ *
71
+ * `approval_requests.chain` is a snapshot of the workflow as it stood when the request was raised,
72
+ * and a step in it can be named after the person it is about. Nulling the column is not an option —
73
+ * it is `not null` and it is the record of who had to sign — so the names come out and the structure
74
+ * stays.
75
+ *
76
+ * Substring rather than equality: a step called "Approval for Ayşe Demir" carries the name as much
77
+ * as one called "Ayşe Demir" does. Case-insensitive, and needles shorter than three characters are
78
+ * ignored, because a one-letter name would blank every string in the document.
79
+ *
80
+ * Reports whether anything changed, so the caller can skip the write — which is what makes an
81
+ * erasure replay report zero rows for this class rather than rewriting identical jsonb.
82
+ */
83
+ export function scrubNames(value, needles, token) {
84
+ const usable = needles.map((n) => n.trim()).filter((n) => n.length >= 3);
85
+ if (!usable.length)
86
+ return { value, changed: false };
87
+ let changed = false;
88
+ const walk = (node) => {
89
+ if (typeof node === 'string') {
90
+ let out = node;
91
+ for (const needle of usable) {
92
+ // Escape the needle: a name is user input and may contain regex metacharacters.
93
+ const pattern = new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
94
+ out = out.replace(pattern, token);
95
+ }
96
+ if (out !== node)
97
+ changed = true;
98
+ return out;
99
+ }
100
+ if (Array.isArray(node))
101
+ return node.map(walk);
102
+ if (node && typeof node === 'object')
103
+ return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, walk(v)]));
104
+ return node;
105
+ };
106
+ const next = walk(value);
107
+ return { value: changed ? next : value, changed };
108
+ }
109
+ /**
110
+ * Drop the `custom` values whose field definition is marked sensitive.
111
+ *
112
+ * A defect that predates this feature and lands in the middle of it: `custom_field_defs.sensitive`
113
+ * is declared, stored, editable and documented as "needs `hr.person.view_sensitive`, like a national
114
+ * identity number" — and nothing has ever read it. `toPerson` returns `custom` whole and `forViewer`
115
+ * nulls only the four personnel fields, so a field an administrator marked sensitive went to every
116
+ * holder of `hr.person.view`, which is a `member` default. That is the same failure as a permission
117
+ * key nothing asks about, one level down.
118
+ *
119
+ * Returns the object it was given when there is nothing to strip, so the common path — no sensitive
120
+ * fields defined, or a reader who holds the permission — allocates nothing.
121
+ */
122
+ export function stripSensitiveCustom(custom, sensitiveKeys) {
123
+ if (!sensitiveKeys.size)
124
+ return custom;
125
+ const present = Object.keys(custom).filter((k) => sensitiveKeys.has(k));
126
+ if (!present.length)
127
+ return custom;
128
+ const out = { ...custom };
129
+ for (const k of present)
130
+ delete out[k];
131
+ return out;
132
+ }
133
+ /** `YYYY-MM-DD`, `days` before `today`. The boundary a retention horizon is measured from. */
134
+ export function retentionCutoff(days, today = todayIso()) {
135
+ const d = new Date(`${today}T00:00:00Z`);
136
+ d.setUTCDate(d.getUTCDate() - days);
137
+ return d.toISOString().slice(0, 10);
138
+ }
139
+ /** Every retention class, in the order a settings screen should show them. */
140
+ export const RETENTION_CLASSES = [
141
+ 'punchDetail',
142
+ 'punches',
143
+ 'attendanceDays',
144
+ 'leave',
145
+ 'personHistory',
146
+ 'personDocuments',
147
+ 'terminatedPeople',
148
+ 'sensitiveAccessLog',
149
+ ];
150
+ /** Null everywhere: the shipped state, and the one this module refuses to guess a number for. */
151
+ export const EMPTY_RETENTION = {
152
+ punchDetail: null,
153
+ punches: null,
154
+ attendanceDays: null,
155
+ leave: null,
156
+ personHistory: null,
157
+ personDocuments: null,
158
+ terminatedPeople: null,
159
+ sensitiveAccessLog: null,
160
+ };
161
+ /**
162
+ * How many rows a subject-access section returns before it is cut.
163
+ *
164
+ * A cut is always named in `manifest.truncated` with its numbers — an export that silently omits is
165
+ * the same failure as an erasure that silently retains. The figures are sized off a five-year
166
+ * employee punching four times a day: about five thousand punches, eighteen hundred day sheets, two
167
+ * hundred and fifty ledger movements. Each cap is a few multiples of that, so a normal record is
168
+ * never cut and a pathological one does not take the process down with it.
169
+ */
170
+ export const SECTION_CAPS = {
171
+ employment: 500,
172
+ offices: 500,
173
+ history: 20_000,
174
+ documents: 1_000,
175
+ leaveRequests: 5_000,
176
+ leaveRequestDays: 20_000,
177
+ leaveLedger: 20_000,
178
+ punches: 40_000,
179
+ attendanceDays: 10_000,
180
+ regularizations: 2_000,
181
+ approvalsRaised: 2_000,
182
+ approverOn: 2_000,
183
+ decisions: 5_000,
184
+ delegations: 1_000,
185
+ accessLog: 5_000,
186
+ };
187
+ /** `select count(*)` reduced to the number, which is the only thing any caller here wants. */
188
+ const total = async (query) => (await query)[0]?.n ?? 0;
189
+ export class PrivacyService {
190
+ // ------------------------------------------------------------------ retention
191
+ /** The stored horizons, defaulted. A workspace that has never set one gets nulls, not numbers. */
192
+ async retention(tx, workspaceId) {
193
+ const [row] = await tx
194
+ .select()
195
+ .from(retentionSettings)
196
+ .where(eq(retentionSettings.workspaceId, workspaceId))
197
+ .limit(1);
198
+ return {
199
+ retention: { ...EMPTY_RETENTION, ...(row?.config ?? {}) },
200
+ updatedAt: row?.updatedAt ?? null,
201
+ updatedBy: row?.updatedBy ?? null,
202
+ };
203
+ }
204
+ /**
205
+ * Patch the horizons.
206
+ *
207
+ * A field left out is unchanged and a field sent as `null` goes back to "keep indefinitely", which
208
+ * is the same reading `core.settings.setModule` gives a partial write — a caller that has nothing
209
+ * to say about a class must not silently reset it.
210
+ */
211
+ async setRetention(tx, workspaceId, patch, actorUserId) {
212
+ const current = await this.retention(tx, workspaceId);
213
+ const config = { ...current.retention };
214
+ for (const [key, value] of Object.entries(patch))
215
+ config[key] = value ?? null;
216
+ const updatedAt = new Date();
217
+ await tx
218
+ .insert(retentionSettings)
219
+ .values({ workspaceId, config, updatedAt, updatedBy: actorUserId })
220
+ .onConflictDoUpdate({
221
+ target: retentionSettings.workspaceId,
222
+ set: { config, updatedAt, updatedBy: actorUserId },
223
+ });
224
+ return { retention: { ...EMPTY_RETENTION, ...config }, updatedAt, updatedBy: actorUserId };
225
+ }
226
+ /**
227
+ * How much is already past each horizon — the dry run a retention screen shows before anything
228
+ * runs, and the only thing that reads these numbers today.
229
+ *
230
+ * Null for a class with no horizon set: there is nothing to be past. One query per class that has
231
+ * one, which is why the caller asks for this rather than getting it on every read.
232
+ *
233
+ * `attendanceDays` deliberately excludes locked days and `terminatedPeople` excludes anybody
234
+ * already erased, so the number is what a sweep *could* act on rather than what merely matches a
235
+ * date. A count that overstates is a count nobody trusts the second time they check it.
236
+ */
237
+ async retentionCounts(tx, workspaceId, retention, today = todayIso()) {
238
+ const out = {};
239
+ for (const cls of RETENTION_CLASSES)
240
+ out[cls] = null;
241
+ const one = async (cls, run) => {
242
+ const days = retention[cls];
243
+ if (days === null)
244
+ return;
245
+ out[cls] = await run(retentionCutoff(days, today));
246
+ };
247
+ await one('punchDetail', (cutoff) => total(tx
248
+ .select({ n: count() })
249
+ .from(punches)
250
+ .where(and(eq(punches.workspaceId, workspaceId), lt(punches.businessDate, cutoff), or(isNotNull(punches.geo), isNotNull(punches.deviceId), isNotNull(punches.note))))));
251
+ await one('punches', (cutoff) => total(tx
252
+ .select({ n: count() })
253
+ .from(punches)
254
+ .where(and(eq(punches.workspaceId, workspaceId), lt(punches.businessDate, cutoff)))));
255
+ await one('attendanceDays', (cutoff) => total(tx
256
+ .select({ n: count() })
257
+ .from(attendanceDays)
258
+ .where(and(eq(attendanceDays.workspaceId, workspaceId), lt(attendanceDays.businessDate, cutoff), eq(attendanceDays.locked, false)))));
259
+ await one('leave', (cutoff) => total(tx
260
+ .select({ n: count() })
261
+ .from(leaveLedger)
262
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), lt(leaveLedger.effectiveOn, cutoff)))));
263
+ await one('personHistory', (cutoff) => total(tx
264
+ .select({ n: count() })
265
+ .from(personHistory)
266
+ .where(and(eq(personHistory.workspaceId, workspaceId), lt(personHistory.at, new Date(`${cutoff}T00:00:00Z`)), or(isNotNull(personHistory.from), isNotNull(personHistory.to))))));
267
+ await one('personDocuments', (cutoff) => total(tx
268
+ .select({ n: count() })
269
+ .from(personDocuments)
270
+ .where(and(eq(personDocuments.workspaceId, workspaceId), lt(personDocuments.createdAt, new Date(`${cutoff}T00:00:00Z`))))));
271
+ await one('terminatedPeople', (cutoff) => total(tx
272
+ .select({ n: count() })
273
+ .from(people)
274
+ .where(and(eq(people.workspaceId, workspaceId), isNotNull(people.terminatedOn), lt(people.terminatedOn, cutoff), isNull(people.erasedAt)))));
275
+ await one('sensitiveAccessLog', (cutoff) => total(tx
276
+ .select({ n: count() })
277
+ .from(sensitiveAccessLog)
278
+ .where(and(eq(sensitiveAccessLog.workspaceId, workspaceId), lt(sensitiveAccessLog.at, new Date(`${cutoff}T00:00:00Z`))))));
279
+ return out;
280
+ }
281
+ // ------------------------------------------------------------------ erasure
282
+ /**
283
+ * Redact one person, or say what redacting them would do.
284
+ *
285
+ * Runs inside the caller's transaction, so the whole erasure commits or none of it does — a
286
+ * half-run erasure is worse than a refused one, and this is the only way to be sure there is no
287
+ * such state to be in.
288
+ */
289
+ async erase(tx, opts) {
290
+ const { workspaceId, personId, dryRun } = opts;
291
+ const [person] = await tx
292
+ .select()
293
+ .from(people)
294
+ .where(and(eq(people.workspaceId, workspaceId), eq(people.id, personId)))
295
+ .limit(1);
296
+ if (!person)
297
+ throw KernError.notFound('Person');
298
+ const caveats = new Set();
299
+ const token = erasureDisplayName(person);
300
+ const names = [person.displayName].filter((n) => n !== token);
301
+ // ---- files this erasure orphans, gathered before the pointers are cleared -------------
302
+ const leaveDocs = await tx
303
+ .select({ fileId: leaveRequests.documentFileId })
304
+ .from(leaveRequests)
305
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.personId, personId), isNotNull(leaveRequests.documentFileId)));
306
+ const orphaned = [
307
+ ...(person.photoFileId ? [person.photoFileId] : []),
308
+ ...leaveDocs.map((r) => r.fileId).filter((id) => id !== null),
309
+ ];
310
+ const filesRemaining = [...new Set([...(person.erasedFileIds ?? []), ...orphaned])];
311
+ if (person.photoFileId)
312
+ caveats.add('photoFileOrphaned');
313
+ if (leaveDocs.length)
314
+ caveats.add('leaveDocumentFilesRemain');
315
+ /**
316
+ * Punches inside a closed month are left exactly as they are.
317
+ *
318
+ * The module's standing rule is that a locked period does not move, and `hr.period.manage` — an
319
+ * owner's key — is what unlocks one. Clearing a note or a location does not change a figure, but
320
+ * it is still a write into a month a payroll has been filed against, and quietly making an
321
+ * exception for erasure is how "locked" stops meaning anything. So these rows are skipped, the
322
+ * report says so, and because erasure is replayable the owner unlocks and runs it again to
323
+ * finish the job. Surfacing the decision rather than encoding it.
324
+ */
325
+ const notLocked = sql `not exists (select 1 from ${attendanceDays} ad
326
+ where ad.workspace_id = ${punches.workspaceId}
327
+ and ad.person_id = ${punches.personId}
328
+ and ad.business_date = ${punches.businessDate}
329
+ and ad.locked)`;
330
+ const punchesDirty = or(isNotNull(punches.geo), isNotNull(punches.deviceId), isNotNull(punches.note), isNotNull(punches.clientReportedAt));
331
+ const lockedPunches = await total(tx
332
+ .select({ n: count() })
333
+ .from(punches)
334
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId), punchesDirty, sql `not (${notLocked})`)));
335
+ if (lockedPunches > 0)
336
+ caveats.add('lockedPeriodUntouched');
337
+ // ---- the steps -----------------------------------------------------------------------
338
+ const now = new Date();
339
+ const sensitiveDirty = [
340
+ isNotNull(peopleSensitive.birthDate),
341
+ isNotNull(peopleSensitive.ibanEnc),
342
+ isNotNull(peopleSensitive.emergencyContact),
343
+ ];
344
+ if (!opts.keepNationalIdForAudit)
345
+ sensitiveDirty.push(isNotNull(peopleSensitive.nationalIdEnc));
346
+ if (opts.keepNationalIdForAudit)
347
+ caveats.add('nationalIdKeptForAudit');
348
+ const steps = [
349
+ {
350
+ class: 'identity',
351
+ table: 'people',
352
+ columns: [
353
+ 'userId',
354
+ 'displayName',
355
+ 'workEmail',
356
+ 'personalEmail',
357
+ 'phone',
358
+ 'photoFileId',
359
+ 'timezone',
360
+ 'custom',
361
+ ],
362
+ count: (t) => total(t
363
+ .select({ n: count() })
364
+ .from(people)
365
+ .where(and(eq(people.workspaceId, workspaceId), eq(people.id, personId), isNull(people.erasedAt)))),
366
+ apply: async (t) => {
367
+ await t
368
+ .update(people)
369
+ .set({
370
+ userId: null,
371
+ displayName: token,
372
+ workEmail: null,
373
+ personalEmail: null,
374
+ phone: null,
375
+ photoFileId: null,
376
+ timezone: null,
377
+ custom: {},
378
+ erasedAt: now,
379
+ erasedBy: opts.actorUserId,
380
+ erasureReason: opts.reason,
381
+ erasedFileIds: filesRemaining.length ? filesRemaining : null,
382
+ updatedAt: now,
383
+ })
384
+ .where(and(eq(people.workspaceId, workspaceId), eq(people.id, personId), isNull(people.erasedAt)));
385
+ },
386
+ },
387
+ {
388
+ class: 'sensitive',
389
+ table: 'people_sensitive',
390
+ columns: opts.keepNationalIdForAudit
391
+ ? ['birthDate', 'iban', 'emergencyContact']
392
+ : ['nationalId', 'birthDate', 'iban', 'emergencyContact'],
393
+ count: (t) => total(t
394
+ .select({ n: count() })
395
+ .from(peopleSensitive)
396
+ .where(and(eq(peopleSensitive.workspaceId, workspaceId), eq(peopleSensitive.personId, personId), or(...sensitiveDirty)))),
397
+ apply: async (t) => {
398
+ // The row survives rather than being deleted: "sensitive data was held here and cleared on
399
+ // this date" is exactly what the erasure response has to be able to say afterwards.
400
+ await t
401
+ .update(peopleSensitive)
402
+ .set({
403
+ ...(opts.keepNationalIdForAudit ? {} : { nationalIdEnc: null }),
404
+ birthDate: null,
405
+ ibanEnc: null,
406
+ emergencyContact: null,
407
+ updatedAt: now,
408
+ })
409
+ .where(and(eq(peopleSensitive.workspaceId, workspaceId), eq(peopleSensitive.personId, personId), or(...sensitiveDirty)));
410
+ },
411
+ },
412
+ {
413
+ // A pointer, not a record. An erased person heading a department renders as a tombstone at
414
+ // the top of the org chart, which is the one place the redaction is loudest.
415
+ class: 'headship',
416
+ table: 'offices',
417
+ columns: ['headPersonId'],
418
+ count: (t) => total(t
419
+ .select({ n: count() })
420
+ .from(offices)
421
+ .where(and(eq(offices.workspaceId, workspaceId), eq(offices.headPersonId, personId)))),
422
+ apply: async (t) => {
423
+ await t
424
+ .update(offices)
425
+ .set({ headPersonId: null })
426
+ .where(and(eq(offices.workspaceId, workspaceId), eq(offices.headPersonId, personId)));
427
+ },
428
+ },
429
+ {
430
+ class: 'headship',
431
+ table: 'org_units',
432
+ columns: ['headPersonId'],
433
+ count: (t) => total(t
434
+ .select({ n: count() })
435
+ .from(orgUnits)
436
+ .where(and(eq(orgUnits.workspaceId, workspaceId), eq(orgUnits.headPersonId, personId)))),
437
+ apply: async (t) => {
438
+ await t
439
+ .update(orgUnits)
440
+ .set({ headPersonId: null })
441
+ .where(and(eq(orgUnits.workspaceId, workspaceId), eq(orgUnits.headPersonId, personId)));
442
+ },
443
+ },
444
+ {
445
+ // The values go, the rows stay: "personalEmail changed on 3 March, by X" survives and the
446
+ // address does not. Erasing the trail and erasing the data are different acts.
447
+ class: 'history',
448
+ table: 'person_history',
449
+ columns: ['from', 'to'],
450
+ count: (t) => total(t.select({ n: count() }).from(personHistory).where(this.historyWhere(workspaceId, personId))),
451
+ apply: async (t) => {
452
+ await t
453
+ .update(personHistory)
454
+ .set({ from: null, to: null })
455
+ .where(this.historyWhere(workspaceId, personId));
456
+ },
457
+ },
458
+ {
459
+ // Where somebody was, on what device, and what they typed. Far beyond what an attendance
460
+ // dispute needs; the direction, the instant and the business date are what it does need.
461
+ class: 'punches',
462
+ table: 'punches',
463
+ columns: ['geo', 'deviceId', 'note', 'clientReportedAt'],
464
+ count: (t) => total(t
465
+ .select({ n: count() })
466
+ .from(punches)
467
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId), punchesDirty, notLocked))),
468
+ apply: async (t) => {
469
+ await t
470
+ .update(punches)
471
+ .set({ geo: null, deviceId: null, note: null, clientReportedAt: null })
472
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId), punchesDirty, notLocked));
473
+ },
474
+ },
475
+ {
476
+ // `reason` on a leave request is routinely health data — "chemotherapy", "funeral". The
477
+ // dates, the working days and the status are the pay record and stay.
478
+ class: 'leaveRequests',
479
+ table: 'leave_requests',
480
+ columns: ['reason', 'documentFileId'],
481
+ count: (t) => total(t
482
+ .select({ n: count() })
483
+ .from(leaveRequests)
484
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.personId, personId), or(isNotNull(leaveRequests.reason), isNotNull(leaveRequests.documentFileId))))),
485
+ apply: async (t) => {
486
+ await t
487
+ .update(leaveRequests)
488
+ .set({ reason: null, documentFileId: null, updatedAt: now })
489
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.personId, personId), or(isNotNull(leaveRequests.reason), isNotNull(leaveRequests.documentFileId))));
490
+ },
491
+ },
492
+ {
493
+ class: 'leaveLedger',
494
+ table: 'leave_ledger',
495
+ columns: ['reason'],
496
+ count: (t) => total(t
497
+ .select({ n: count() })
498
+ .from(leaveLedger)
499
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId), isNotNull(leaveLedger.reason)))),
500
+ apply: async (t) => {
501
+ await t
502
+ .update(leaveLedger)
503
+ .set({ reason: null })
504
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId), isNotNull(leaveLedger.reason)));
505
+ },
506
+ },
507
+ {
508
+ // "performance", "returning from maternity leave" — free text about a person, on a row whose
509
+ // dates and hours are the statutory record.
510
+ class: 'employment',
511
+ table: 'employments',
512
+ columns: ['reason'],
513
+ count: (t) => total(t
514
+ .select({ n: count() })
515
+ .from(employments)
516
+ .where(and(eq(employments.workspaceId, workspaceId), eq(employments.personId, personId), isNotNull(employments.reason)))),
517
+ apply: async (t) => {
518
+ await t
519
+ .update(employments)
520
+ .set({ reason: null })
521
+ .where(and(eq(employments.workspaceId, workspaceId), eq(employments.personId, personId), isNotNull(employments.reason)));
522
+ },
523
+ },
524
+ {
525
+ class: 'officeAssignments',
526
+ table: 'office_assignments',
527
+ columns: ['reason'],
528
+ count: (t) => total(t
529
+ .select({ n: count() })
530
+ .from(officeAssignments)
531
+ .where(and(eq(officeAssignments.workspaceId, workspaceId), eq(officeAssignments.personId, personId), isNotNull(officeAssignments.reason)))),
532
+ apply: async (t) => {
533
+ await t
534
+ .update(officeAssignments)
535
+ .set({ reason: null })
536
+ .where(and(eq(officeAssignments.workspaceId, workspaceId), eq(officeAssignments.personId, personId), isNotNull(officeAssignments.reason)));
537
+ },
538
+ },
539
+ {
540
+ // `reason` is `not null` here, so it is emptied rather than nulled. `proposed` and `status`
541
+ // stay: they justify a corrected day sheet that payroll relies on.
542
+ class: 'regularizations',
543
+ table: 'regularizations',
544
+ columns: ['reason'],
545
+ count: (t) => total(t
546
+ .select({ n: count() })
547
+ .from(regularizations)
548
+ .where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.personId, personId), ne(regularizations.reason, '')))),
549
+ apply: async (t) => {
550
+ await t
551
+ .update(regularizations)
552
+ .set({ reason: '' })
553
+ .where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.personId, personId), ne(regularizations.reason, '')));
554
+ },
555
+ },
556
+ {
557
+ class: 'delegations',
558
+ table: 'delegations',
559
+ columns: ['reason'],
560
+ count: (t) => total(t.select({ n: count() }).from(delegations).where(this.delegationWhere(workspaceId, personId))),
561
+ apply: async (t) => {
562
+ await t.update(delegations).set({ reason: null }).where(this.delegationWhere(workspaceId, personId));
563
+ },
564
+ },
565
+ ];
566
+ const redacted = [];
567
+ for (const step of steps) {
568
+ const rows = await step.count(tx);
569
+ if (rows > 0 && !dryRun)
570
+ await step.apply(tx);
571
+ redacted.push({ class: step.class, table: step.table, rows, columns: step.columns });
572
+ }
573
+ // ---- the two that cannot be one predicate ---------------------------------------------
574
+ redacted.push(await this.redactApprovals(tx, workspaceId, personId, names, token, dryRun));
575
+ redacted.push(await this.redactDecisions(tx, workspaceId, personId, dryRun));
576
+ // ---- what survived, and on what basis --------------------------------------------------
577
+ const { retention } = await this.retention(tx, workspaceId);
578
+ const kept = await this.keptClasses(tx, workspaceId, personId, retention);
579
+ if (kept.some((k) => k.class === 'documents' && k.rows > 0))
580
+ caveats.add('documentFilesRemain');
581
+ if (kept.some((k) => k.class === 'history' && k.basis === 'anotherPersonsRecord' && k.rows > 0))
582
+ caveats.add('actorHistoryKept');
583
+ return {
584
+ // A replay keeps the first erasure's date rather than restamping it: the second run finds
585
+ // nothing left to clear, and moving the timestamp would misdate the act for the one field
586
+ // somebody would later be asked to produce.
587
+ erasedAt: person.erasedAt ?? (dryRun ? null : now),
588
+ // The token on both paths, never the name being replaced. A dry run is what a confirmation
589
+ // dialog reads before somebody presses through, so "this is the name the directory will show"
590
+ // is the useful answer — and echoing the real name back would put it in a response whose whole
591
+ // subject is removing it. Already-erased rows recompute to the same token, because it is
592
+ // derived from `employee_no` and the row id, neither of which an erasure changes.
593
+ displayName: token,
594
+ redacted,
595
+ kept,
596
+ caveats: [...caveats],
597
+ filesRemaining,
598
+ };
599
+ }
600
+ historyWhere(workspaceId, personId) {
601
+ return and(eq(personHistory.workspaceId, workspaceId), eq(personHistory.personId, personId), inArray(personHistory.field, [...REDACTED_HISTORY_FIELDS]), or(isNotNull(personHistory.from), isNotNull(personHistory.to)));
602
+ }
603
+ delegationWhere(workspaceId, personId) {
604
+ return and(eq(delegations.workspaceId, workspaceId), or(eq(delegations.fromPersonId, personId), eq(delegations.toPersonId, personId)), isNotNull(delegations.reason));
605
+ }
606
+ /**
607
+ * The approval requests this person raised: the English summary, the same sentence as data, and
608
+ * any name embedded in the snapshotted chain.
609
+ *
610
+ * Row by row rather than one `update`, because the chain is jsonb that has to be read to be
611
+ * scrubbed. The set is small — an approval request per leave request — and skipping rows that
612
+ * scrub to themselves is what keeps a replay at zero.
613
+ */
614
+ async redactApprovals(tx, workspaceId, personId, names, token, dryRun) {
615
+ const rows = await tx
616
+ .select({
617
+ id: approvalRequests.id,
618
+ summary: approvalRequests.summary,
619
+ chain: approvalRequests.chain,
620
+ params: approvalRequests.summaryParams,
621
+ })
622
+ .from(approvalRequests)
623
+ .where(and(eq(approvalRequests.workspaceId, workspaceId), eq(approvalRequests.requesterPersonId, personId)));
624
+ let touched = 0;
625
+ for (const row of rows) {
626
+ const scrubbed = scrubNames(row.chain, names, token);
627
+ const dirty = row.summary !== '' || row.params !== null || scrubbed.changed;
628
+ if (!dirty)
629
+ continue;
630
+ touched += 1;
631
+ if (dryRun)
632
+ continue;
633
+ await tx
634
+ .update(approvalRequests)
635
+ .set({
636
+ summary: '',
637
+ summaryParams: null,
638
+ chain: scrubbed.value,
639
+ })
640
+ .where(eq(approvalRequests.id, row.id));
641
+ }
642
+ return {
643
+ class: 'approvals',
644
+ table: 'approval_requests',
645
+ rows: touched,
646
+ columns: ['summary', 'summaryParams', 'chain'],
647
+ };
648
+ }
649
+ /**
650
+ * Approver comments, on both paths.
651
+ *
652
+ * A comment is free text an approver wrote. On the subject's erasure it is somebody talking about
653
+ * the subject; on the approver's own erasure it is the approver's own speech. Both are personal
654
+ * data about the person being erased, so both are cleared — the decision itself, who made it and
655
+ * when, are the authorisation record and stay.
656
+ */
657
+ async redactDecisions(tx, workspaceId, personId, dryRun) {
658
+ const requestIds = (await tx
659
+ .select({ id: approvalRequests.id })
660
+ .from(approvalRequests)
661
+ .where(and(eq(approvalRequests.workspaceId, workspaceId), eq(approvalRequests.requesterPersonId, personId)))).map((r) => r.id);
662
+ const stepIds = requestIds.length
663
+ ? (await tx
664
+ .select({ id: approvalSteps.id })
665
+ .from(approvalSteps)
666
+ .where(and(eq(approvalSteps.workspaceId, workspaceId), inArray(approvalSteps.requestId, requestIds)))).map((r) => r.id)
667
+ : [];
668
+ const target = stepIds.length
669
+ ? or(eq(approvalDecisions.approverId, personId), inArray(approvalDecisions.stepId, stepIds))
670
+ : eq(approvalDecisions.approverId, personId);
671
+ const where = and(eq(approvalDecisions.workspaceId, workspaceId), target, isNotNull(approvalDecisions.comment));
672
+ const [row] = await tx.select({ n: count() }).from(approvalDecisions).where(where);
673
+ const rows = row?.n ?? 0;
674
+ if (rows > 0 && !dryRun)
675
+ await tx.update(approvalDecisions).set({ comment: null }).where(where);
676
+ return { class: 'approvalDecisions', table: 'approval_decisions', rows, columns: ['comment'] };
677
+ }
678
+ /**
679
+ * The records that survive, counted, with the basis each survives under.
680
+ *
681
+ * `retentionHorizon` wins over the statutory reason wherever the workspace has actually set one:
682
+ * with a horizon in force, the horizon is the operative answer to "why is this still here", and
683
+ * the number the administrator typed is the thing they will recognise. Without one it falls back
684
+ * to what the record is — a wage, an entitlement, or an authorisation.
685
+ */
686
+ async keptClasses(tx, workspaceId, personId, retention) {
687
+ const basis = (days, fallback) => days === null ? fallback : 'retentionHorizon';
688
+ const out = [
689
+ {
690
+ class: 'employment',
691
+ table: 'employments',
692
+ rows: await total(tx
693
+ .select({ n: count() })
694
+ .from(employments)
695
+ .where(and(eq(employments.workspaceId, workspaceId), eq(employments.personId, personId)))),
696
+ basis: 'payRecord',
697
+ retentionDays: null,
698
+ },
699
+ {
700
+ class: 'officeAssignments',
701
+ table: 'office_assignments',
702
+ rows: await total(tx
703
+ .select({ n: count() })
704
+ .from(officeAssignments)
705
+ .where(and(eq(officeAssignments.workspaceId, workspaceId), eq(officeAssignments.personId, personId)))),
706
+ basis: 'payRecord',
707
+ retentionDays: null,
708
+ },
709
+ {
710
+ class: 'leaveLedger',
711
+ table: 'leave_ledger',
712
+ rows: await total(tx
713
+ .select({ n: count() })
714
+ .from(leaveLedger)
715
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId)))),
716
+ basis: basis(retention.leave, 'payRecord'),
717
+ retentionDays: retention.leave,
718
+ },
719
+ {
720
+ class: 'leaveRequests',
721
+ table: 'leave_requests',
722
+ rows: await total(tx
723
+ .select({ n: count() })
724
+ .from(leaveRequests)
725
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.personId, personId)))),
726
+ basis: basis(retention.leave, 'payRecord'),
727
+ retentionDays: retention.leave,
728
+ },
729
+ {
730
+ class: 'attendance',
731
+ table: 'attendance_days',
732
+ rows: await total(tx
733
+ .select({ n: count() })
734
+ .from(attendanceDays)
735
+ .where(and(eq(attendanceDays.workspaceId, workspaceId), eq(attendanceDays.personId, personId)))),
736
+ basis: basis(retention.attendanceDays, 'payRecord'),
737
+ retentionDays: retention.attendanceDays,
738
+ },
739
+ {
740
+ class: 'punches',
741
+ table: 'punches',
742
+ rows: await total(tx
743
+ .select({ n: count() })
744
+ .from(punches)
745
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId)))),
746
+ basis: basis(retention.punches, 'payRecord'),
747
+ retentionDays: retention.punches,
748
+ },
749
+ {
750
+ class: 'history',
751
+ table: 'person_history',
752
+ rows: await total(tx
753
+ .select({ n: count() })
754
+ .from(personHistory)
755
+ .where(and(eq(personHistory.workspaceId, workspaceId), eq(personHistory.personId, personId)))),
756
+ basis: basis(retention.personHistory, 'auditTrail'),
757
+ retentionDays: retention.personHistory,
758
+ },
759
+ {
760
+ /**
761
+ * Rows where this person was the **actor** on somebody else's record.
762
+ *
763
+ * Left alone, values and all. Erasing A is not authority to rewrite the trail of what A did
764
+ * to B's record — that trail is B's, and B did not ask for anything.
765
+ */
766
+ class: 'history',
767
+ table: 'person_history',
768
+ rows: await total(tx
769
+ .select({ n: count() })
770
+ .from(personHistory)
771
+ .where(and(eq(personHistory.workspaceId, workspaceId), eq(personHistory.actorId, personId), ne(personHistory.personId, personId)))),
772
+ basis: 'anotherPersonsRecord',
773
+ retentionDays: null,
774
+ },
775
+ {
776
+ /**
777
+ * The rows stay and so do the files, because HR cannot delete a core object: `core.files.get`
778
+ * is the only file procedure a module can reach. Nulling the row would remove the last record
779
+ * of which files those were and leave the passport scan in the bucket regardless — a promise
780
+ * with nothing behind it, and the worst kind, because the response would say the document was
781
+ * erased.
782
+ */
783
+ class: 'documents',
784
+ table: 'person_documents',
785
+ rows: await total(tx
786
+ .select({ n: count() })
787
+ .from(personDocuments)
788
+ .where(and(eq(personDocuments.workspaceId, workspaceId), eq(personDocuments.personId, personId)))),
789
+ basis: 'notRemovable',
790
+ retentionDays: retention.personDocuments,
791
+ },
792
+ {
793
+ class: 'approvals',
794
+ table: 'approval_requests',
795
+ rows: await total(tx
796
+ .select({ n: count() })
797
+ .from(approvalRequests)
798
+ .where(and(eq(approvalRequests.workspaceId, workspaceId), eq(approvalRequests.requesterPersonId, personId)))),
799
+ basis: 'auditTrail',
800
+ retentionDays: null,
801
+ },
802
+ ];
803
+ return out;
804
+ }
805
+ // ------------------------------------------------------------------ subject access
806
+ /**
807
+ * Every row HR holds about one person.
808
+ *
809
+ * Reads only — the sensitive decrypt and its access-log row are the caller's job, because that
810
+ * pairing lives in `PeopleService.readSensitive` and there must go on being exactly one place in
811
+ * this module that decrypts these columns.
812
+ *
813
+ * Each section fetches `cap + 1` rows so a cut is detectable without a second `count`, and every
814
+ * cut is reported. Nothing is dropped in silence.
815
+ */
816
+ async subjectAccess(tx, workspaceId, personId) {
817
+ const truncated = [];
818
+ const cut = (section, rows) => {
819
+ const cap = SECTION_CAPS[section];
820
+ if (rows.length <= cap)
821
+ return rows;
822
+ truncated.push({ section, returned: cap, cap });
823
+ return rows.slice(0, cap);
824
+ };
825
+ const limitOf = (section) => SECTION_CAPS[section] + 1;
826
+ const employment = cut('employment', await tx
827
+ .select()
828
+ .from(employments)
829
+ .where(and(eq(employments.workspaceId, workspaceId), eq(employments.personId, personId)))
830
+ .orderBy(desc(employments.effectiveFrom))
831
+ .limit(limitOf('employment')));
832
+ const officeRows = cut('offices', await tx
833
+ .select()
834
+ .from(officeAssignments)
835
+ .where(and(eq(officeAssignments.workspaceId, workspaceId), eq(officeAssignments.personId, personId)))
836
+ .orderBy(desc(officeAssignments.effectiveFrom))
837
+ .limit(limitOf('offices')));
838
+ const history = cut('history', await tx
839
+ .select()
840
+ .from(personHistory)
841
+ .where(and(eq(personHistory.workspaceId, workspaceId), eq(personHistory.personId, personId)))
842
+ .orderBy(desc(personHistory.at), desc(personHistory.id))
843
+ .limit(limitOf('history')));
844
+ const documents = cut('documents', await tx
845
+ .select()
846
+ .from(personDocuments)
847
+ .where(and(eq(personDocuments.workspaceId, workspaceId), eq(personDocuments.personId, personId)))
848
+ .orderBy(desc(personDocuments.createdAt))
849
+ .limit(limitOf('documents')));
850
+ const requests = cut('leaveRequests', await tx
851
+ .select()
852
+ .from(leaveRequests)
853
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.personId, personId)))
854
+ .orderBy(desc(leaveRequests.startsOn))
855
+ .limit(limitOf('leaveRequests')));
856
+ const days = cut('leaveRequestDays', await tx
857
+ .select()
858
+ .from(leaveRequestDays)
859
+ .where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId)))
860
+ .orderBy(asc(leaveRequestDays.date))
861
+ .limit(limitOf('leaveRequestDays')));
862
+ // Oldest first: the ledger is only meaningful read in order, and the running balance in the
863
+ // bundle is the reason it is sent that way rather than newest-first like everything else.
864
+ const ledger = cut('leaveLedger', await tx
865
+ .select()
866
+ .from(leaveLedger)
867
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId)))
868
+ .orderBy(asc(leaveLedger.effectiveOn), asc(leaveLedger.id))
869
+ .limit(limitOf('leaveLedger')));
870
+ const typeIds = [...new Set(ledger.map((r) => r.leaveTypeId).concat(requests.map((r) => r.leaveTypeId)))];
871
+ const types = typeIds.length
872
+ ? await tx
873
+ .select()
874
+ .from(leaveTypes)
875
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), inArray(leaveTypes.id, typeIds)))
876
+ : [];
877
+ const punchRows = cut('punches', await tx
878
+ .select()
879
+ .from(punches)
880
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId)))
881
+ .orderBy(desc(punches.businessDate), desc(punches.at))
882
+ .limit(limitOf('punches')));
883
+ const dayRows = cut('attendanceDays', await tx
884
+ .select()
885
+ .from(attendanceDays)
886
+ .where(and(eq(attendanceDays.workspaceId, workspaceId), eq(attendanceDays.personId, personId)))
887
+ .orderBy(desc(attendanceDays.businessDate))
888
+ .limit(limitOf('attendanceDays')));
889
+ const regs = cut('regularizations', await tx
890
+ .select()
891
+ .from(regularizations)
892
+ .where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.personId, personId)))
893
+ .orderBy(desc(regularizations.businessDate))
894
+ .limit(limitOf('regularizations')));
895
+ const raised = cut('approvalsRaised', await tx
896
+ .select()
897
+ .from(approvalRequests)
898
+ .where(and(eq(approvalRequests.workspaceId, workspaceId), eq(approvalRequests.requesterPersonId, personId)))
899
+ .orderBy(desc(approvalRequests.requestedAt))
900
+ .limit(limitOf('approvalsRaised')));
901
+ // `approver_ids` is a uuid[] of **person** ids, and the gin index on it is what makes this an
902
+ // overlap lookup rather than a scan of every step the workspace has ever raised.
903
+ const approverOn = cut('approverOn', await tx
904
+ .select()
905
+ .from(approvalSteps)
906
+ .where(and(eq(approvalSteps.workspaceId, workspaceId), sql `${approvalSteps.approverIds} && array[${personId}]::uuid[]`))
907
+ .orderBy(desc(approvalSteps.id))
908
+ .limit(limitOf('approverOn')));
909
+ const ownDecisions = cut('decisions', await tx
910
+ .select()
911
+ .from(approvalDecisions)
912
+ .where(and(eq(approvalDecisions.workspaceId, workspaceId), eq(approvalDecisions.approverId, personId)))
913
+ .orderBy(desc(approvalDecisions.at))
914
+ .limit(limitOf('decisions')));
915
+ // Steps of their own requests, plus the steps they were named on, so `approverOn` can carry its
916
+ // decisions rather than being a list of steps with no outcome.
917
+ const raisedStepIds = raised.length
918
+ ? (await tx
919
+ .select({ id: approvalSteps.id })
920
+ .from(approvalSteps)
921
+ .where(and(eq(approvalSteps.workspaceId, workspaceId), inArray(approvalSteps.requestId, raised.map((r) => r.id))))).map((r) => r.id)
922
+ : [];
923
+ const stepIds = [...new Set([...raisedStepIds, ...approverOn.map((s) => s.id)])];
924
+ const stepDecisions = stepIds.length
925
+ ? await tx
926
+ .select()
927
+ .from(approvalDecisions)
928
+ .where(and(eq(approvalDecisions.workspaceId, workspaceId), inArray(approvalDecisions.stepId, stepIds)))
929
+ : [];
930
+ const raisedSteps = raisedStepIds.length
931
+ ? await tx
932
+ .select()
933
+ .from(approvalSteps)
934
+ .where(and(eq(approvalSteps.workspaceId, workspaceId), inArray(approvalSteps.id, raisedStepIds)))
935
+ .orderBy(asc(approvalSteps.stepIndex))
936
+ : [];
937
+ const given = cut('delegations', await tx
938
+ .select()
939
+ .from(delegations)
940
+ .where(and(eq(delegations.workspaceId, workspaceId), eq(delegations.fromPersonId, personId)))
941
+ .orderBy(desc(delegations.startsOn))
942
+ .limit(limitOf('delegations')));
943
+ const received = cut('delegations', await tx
944
+ .select()
945
+ .from(delegations)
946
+ .where(and(eq(delegations.workspaceId, workspaceId), eq(delegations.toPersonId, personId)))
947
+ .orderBy(desc(delegations.startsOn))
948
+ .limit(limitOf('delegations')));
949
+ const accessLog = cut('accessLog', await tx
950
+ .select()
951
+ .from(sensitiveAccessLog)
952
+ .where(and(eq(sensitiveAccessLog.workspaceId, workspaceId), eq(sensitiveAccessLog.personId, personId)))
953
+ .orderBy(desc(sensitiveAccessLog.at), desc(sensitiveAccessLog.id))
954
+ .limit(limitOf('accessLog')));
955
+ return {
956
+ employment,
957
+ offices: officeRows,
958
+ history,
959
+ documents,
960
+ leave: { types, requests, days, ledger },
961
+ attendance: { punches: punchRows, days: dayRows },
962
+ regularizations: regs,
963
+ approvals: { raised, raisedSteps, approverOn, stepDecisions, decisions: ownDecisions },
964
+ delegations: { given, received },
965
+ accessLog,
966
+ truncated,
967
+ };
968
+ }
969
+ }
970
+ /** The running total down an ordered ledger. "Why is my balance this number", answered in the file. */
971
+ export const closingBalance = (ledger) => ledger.reduce((sum, entry) => sum + entry.amountMinutes, 0);
972
+ //# sourceMappingURL=privacy.js.map