@kernhq/module-hr 0.16.0 → 0.17.1
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.
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +1 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +15 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +8 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +20 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/privacy.d.ts +860 -0
- package/dist/contract/privacy.d.ts.map +1 -0
- package/dist/contract/privacy.js +412 -0
- package/dist/contract/privacy.js.map +1 -0
- package/dist/contract/router.d.ts +815 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +105 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +876 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +312 -2
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +397 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +109 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/audit.d.ts +249 -0
- package/dist/server/services/audit.d.ts.map +1 -0
- package/dist/server/services/audit.js +230 -0
- package/dist/server/services/audit.js.map +1 -0
- package/dist/server/services/people.d.ts +53 -1
- package/dist/server/services/people.d.ts.map +1 -1
- package/dist/server/services/people.js +74 -1
- package/dist/server/services/people.js.map +1 -1
- package/dist/server/services/privacy.d.ts +514 -0
- package/dist/server/services/privacy.d.ts.map +1 -0
- package/dist/server/services/privacy.js +972 -0
- package/dist/server/services/privacy.js.map +1 -0
- package/migrations/0011_privacy.sql +77 -0
- package/migrations/meta/0011_snapshot.json +4450 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +15 -0
- package/src/contract/permissions.ts +22 -0
- package/src/contract/privacy.ts +452 -0
- 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
|