@kernhq/module-hr 0.15.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.
- 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/index.d.ts.map +1 -1
- package/dist/server/index.js +51 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +44 -7
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +994 -1
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +766 -282
- 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/client/messages.ts +17 -0
- package/src/client/widgets/ApprovalsWidget.svelte +119 -8
- 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
package/dist/server/router.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { KernError, requires, requiresCapability, uuidv7, workspaceScoped, } from '@kernhq/kernel';
|
|
1
|
+
import { KernError, packageVersion, requires, requiresCapability, uuidv7, workspaceScoped, } from '@kernhq/kernel';
|
|
2
2
|
import { implement } from '@orpc/server';
|
|
3
3
|
import { and, asc, count, desc, eq, getTableColumns, gte, ilike, inArray, isNull, lte, or, sql, } from 'drizzle-orm';
|
|
4
4
|
import { HrSettings, hrContract, hrEvents, MODULE_ID } from '../contract/index.js';
|
|
@@ -10,12 +10,16 @@ import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, att
|
|
|
10
10
|
import { forViewer, HrAccessService, seesRecordOf, visibleSet } from './services/access.js';
|
|
11
11
|
import { ApprovalService } from './services/approvals.js';
|
|
12
12
|
import { AttendanceService } from './services/attendance.js';
|
|
13
|
+
import { accessLogSort, HrAuditService } from './services/audit.js';
|
|
13
14
|
import { inForceOn, todayIso } from './services/db.js';
|
|
14
15
|
import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
|
|
15
16
|
import { PeopleService } from './services/people.js';
|
|
16
17
|
import { hashConfig, PolicyService } from './services/policies.js';
|
|
18
|
+
import { closingBalance, PrivacyService, RETENTION_CLASSES, stripSensitiveCustom, } from './services/privacy.js';
|
|
17
19
|
import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
|
|
18
20
|
const os = implement(hrContract).$context();
|
|
21
|
+
/** Shared so the ordinary case — no sensitive custom fields defined — allocates nothing per page. */
|
|
22
|
+
const NO_HIDDEN_FIELDS = new Set();
|
|
19
23
|
const encodeCursor = (key, id) => Buffer.from(JSON.stringify([key, id]), 'utf8').toString('base64url');
|
|
20
24
|
function decodeCursor(raw) {
|
|
21
25
|
if (!raw)
|
|
@@ -84,6 +88,332 @@ function paginate(rows, limit, cursorOf) {
|
|
|
84
88
|
const [key, id] = cursorOf(items[items.length - 1]);
|
|
85
89
|
return { items, nextCursor: encodeCursor(key, id) };
|
|
86
90
|
}
|
|
91
|
+
async function loadCalendar(tx, workspaceId, calendarId) {
|
|
92
|
+
const [row] = await tx
|
|
93
|
+
.select()
|
|
94
|
+
.from(calendars)
|
|
95
|
+
.where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, calendarId)))
|
|
96
|
+
.limit(1);
|
|
97
|
+
if (!row)
|
|
98
|
+
throw KernError.notFound('Calendar');
|
|
99
|
+
return row;
|
|
100
|
+
}
|
|
101
|
+
/** The chain nearest-first: this calendar, then whatever it extends. */
|
|
102
|
+
async function calendarChain(tx, workspaceId, calendarId) {
|
|
103
|
+
const chain = [];
|
|
104
|
+
let cursor = calendarId;
|
|
105
|
+
for (let depth = 0; depth < 4 && cursor; depth++) {
|
|
106
|
+
const row = await loadCalendar(tx, workspaceId, cursor);
|
|
107
|
+
chain.push(row);
|
|
108
|
+
cursor = row.extendsId;
|
|
109
|
+
}
|
|
110
|
+
return chain;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The composed calendar over a range: this calendar's days over the ones it extends.
|
|
114
|
+
*
|
|
115
|
+
* Nearest wins per date and kind, and a day that shadows one from a calendar further down is
|
|
116
|
+
* marked `overrides` so the editor can show what it is replacing — which is what makes "we work
|
|
117
|
+
* through this national holiday" legible rather than looking like a missing holiday.
|
|
118
|
+
*
|
|
119
|
+
* At module scope, because it never needed the router's closure — only a `tx`. That is what lets
|
|
120
|
+
* `hrSubjects` below reach it, so the leave calculation a deadline runs reads the same calendar as
|
|
121
|
+
* the one a person runs.
|
|
122
|
+
*/
|
|
123
|
+
async function composedDays(tx, workspaceId, calendarId, from, to) {
|
|
124
|
+
const chain = await calendarChain(tx, workspaceId, calendarId);
|
|
125
|
+
const rows = await tx
|
|
126
|
+
.select()
|
|
127
|
+
.from(calendarDays)
|
|
128
|
+
.where(and(eq(calendarDays.workspaceId, workspaceId), inArray(calendarDays.calendarId, chain.map((c) => c.id)), gte(calendarDays.date, from), lte(calendarDays.date, to)));
|
|
129
|
+
const nameById = new Map(chain.map((c) => [c.id, c.name]));
|
|
130
|
+
const seen = new Map();
|
|
131
|
+
const datesFromNearest = new Set();
|
|
132
|
+
for (const cal of chain) {
|
|
133
|
+
for (const row of rows.filter((r) => r.calendarId === cal.id)) {
|
|
134
|
+
const key = `${row.date}:${row.kind}`;
|
|
135
|
+
if (seen.has(key))
|
|
136
|
+
continue;
|
|
137
|
+
const overrides = cal.id !== calendarId ? false : datesFromNearest.has(row.date);
|
|
138
|
+
seen.set(key, toResolvedDay(row, cal.id, nameById.get(cal.id) ?? '', overrides));
|
|
139
|
+
if (cal.id === calendarId)
|
|
140
|
+
datesFromNearest.add(row.date);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Second pass: a nearest-calendar day covering a date the base also has *is* an override, and
|
|
144
|
+
// the first pass cannot know that until the base has been read.
|
|
145
|
+
const baseDates = new Set(rows.filter((r) => r.calendarId !== calendarId).map((r) => r.date));
|
|
146
|
+
return [...seen.values()]
|
|
147
|
+
.map((d) => ({ ...d, overrides: d.fromCalendarId === calendarId && baseDates.has(d.date) }))
|
|
148
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* What a *decided* request does to its subject, and the calculation both halves of that stand on.
|
|
152
|
+
*
|
|
153
|
+
* These lived inside `implement_`'s closure, and that is the whole reason `ApprovalService` had
|
|
154
|
+
* appliers only when a person was on the other end of the call: a job cannot reach into a router's
|
|
155
|
+
* closure, so `sweepTimeouts` would advance an intermediate step and then refuse the step that
|
|
156
|
+
* *completes* a request — logging that it had reminded instead. A deadline an administrator set,
|
|
157
|
+
* believed, and told their staff about did nothing on the one step that mattered.
|
|
158
|
+
*
|
|
159
|
+
* So it is a factory both callers can reach: `implement_` below, and `hrJobs` in `jobs.ts`. It is a
|
|
160
|
+
* factory rather than a class because the closure is the point — `applyApproval` needs `simulate`,
|
|
161
|
+
* `simulate` needs the ledger and the composed calendar, and threading those through method
|
|
162
|
+
* arguments would buy nothing.
|
|
163
|
+
*
|
|
164
|
+
* The alternative was a second `simulate` in the job, and it is worth naming why not: two copies of
|
|
165
|
+
* a leave calculation drift, both of them type-check while they drift, and the first sign of it is
|
|
166
|
+
* an employee whose balance disagrees with the days they were granted.
|
|
167
|
+
*
|
|
168
|
+
* Services are passed in rather than constructed here so a caller keeps one instance of each — two
|
|
169
|
+
* `AttendanceService`s is not a bug today and is one cache away from being one.
|
|
170
|
+
*/
|
|
171
|
+
export function hrSubjects(deps) {
|
|
172
|
+
const { resolve, ledger, attendance } = deps;
|
|
173
|
+
async function loadRequest(tx, workspaceId, requestId) {
|
|
174
|
+
const [row] = await tx
|
|
175
|
+
.select()
|
|
176
|
+
.from(leaveRequests)
|
|
177
|
+
.where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
|
|
178
|
+
.limit(1);
|
|
179
|
+
if (!row)
|
|
180
|
+
throw KernError.notFound('Leave request');
|
|
181
|
+
return row;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* What a request would cost, and every reason it would be refused.
|
|
185
|
+
*
|
|
186
|
+
* Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
|
|
187
|
+
* submission is a preview that eventually lies. The blockers are returned rather than thrown here
|
|
188
|
+
* so the screen can show all of them at once instead of one per round trip.
|
|
189
|
+
*/
|
|
190
|
+
async function simulate(tx, workspaceId, personId, input) {
|
|
191
|
+
const blockers = [];
|
|
192
|
+
if (input.endsOn < input.startsOn)
|
|
193
|
+
blockers.push({ code: 'range', message: 'The end date is before the start date.' });
|
|
194
|
+
const [type] = await tx
|
|
195
|
+
.select()
|
|
196
|
+
.from(leaveTypes)
|
|
197
|
+
.where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
|
|
198
|
+
.limit(1);
|
|
199
|
+
if (!type)
|
|
200
|
+
throw KernError.notFound('Leave type');
|
|
201
|
+
if (type.archivedAt)
|
|
202
|
+
blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
|
|
203
|
+
const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
|
|
204
|
+
const calendarDaysInRange = resolution.calendarId
|
|
205
|
+
? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
|
|
206
|
+
: [];
|
|
207
|
+
const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
|
|
208
|
+
? calendarDaysInRange.map((d) => ({
|
|
209
|
+
date: d.date,
|
|
210
|
+
name: d.name,
|
|
211
|
+
workingFraction: d.workingFraction,
|
|
212
|
+
}))
|
|
213
|
+
: []);
|
|
214
|
+
// Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
|
|
215
|
+
// holiday still costs nothing rather than costing half of nothing.
|
|
216
|
+
const days = results.map((r) => {
|
|
217
|
+
let fraction = r.fraction;
|
|
218
|
+
if (r.date === input.startsOn && input.startPart === 'afternoon')
|
|
219
|
+
fraction = Math.min(fraction, 0.5);
|
|
220
|
+
if (r.date === input.endsOn && input.endPart === 'morning')
|
|
221
|
+
fraction = Math.min(fraction, 0.5);
|
|
222
|
+
return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
|
|
223
|
+
});
|
|
224
|
+
const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
|
|
225
|
+
const minutes = type.unit === 'hour' && input.hours
|
|
226
|
+
? Math.round(input.hours * 60)
|
|
227
|
+
: Math.round(workingDaysTotal * MINUTES_PER_DAY);
|
|
228
|
+
if (minutes <= 0)
|
|
229
|
+
blockers.push({
|
|
230
|
+
code: 'empty',
|
|
231
|
+
message: 'That range contains no working days.',
|
|
232
|
+
});
|
|
233
|
+
const year = yearOf(input.startsOn);
|
|
234
|
+
const balances = await ledger.balances(tx, workspaceId, personId, year);
|
|
235
|
+
const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
|
|
236
|
+
const before = balance?.availableMinutes ?? 0;
|
|
237
|
+
const after = before - minutes;
|
|
238
|
+
if (after < 0 && !type.allowNegative)
|
|
239
|
+
blockers.push({
|
|
240
|
+
code: 'insufficient',
|
|
241
|
+
message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
|
|
242
|
+
});
|
|
243
|
+
if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
|
|
244
|
+
blockers.push({
|
|
245
|
+
code: 'below_floor',
|
|
246
|
+
message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
|
|
247
|
+
});
|
|
248
|
+
// Overlap is refused by a unique index as well; checking here turns a constraint violation into
|
|
249
|
+
// a sentence naming the dates.
|
|
250
|
+
const counted = days.filter((d) => d.counted).map((d) => d.date);
|
|
251
|
+
if (counted.length) {
|
|
252
|
+
const clash = await tx
|
|
253
|
+
.select({ date: leaveRequestDays.date })
|
|
254
|
+
.from(leaveRequestDays)
|
|
255
|
+
.where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
|
|
256
|
+
.limit(3);
|
|
257
|
+
if (clash.length)
|
|
258
|
+
blockers.push({
|
|
259
|
+
code: 'overlap',
|
|
260
|
+
message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
|
|
264
|
+
blockers.push({
|
|
265
|
+
code: 'document_required',
|
|
266
|
+
message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
workingDays: workingDaysTotal,
|
|
270
|
+
minutes,
|
|
271
|
+
days,
|
|
272
|
+
balanceBeforeMinutes: before,
|
|
273
|
+
balanceAfterMinutes: after,
|
|
274
|
+
blockers,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Turn an approved request into a ledger consumption.
|
|
279
|
+
*
|
|
280
|
+
* The working days are **recomputed here** rather than trusted from submission time: a holiday
|
|
281
|
+
* can be added to the calendar between asking and approving, and the number that costs somebody
|
|
282
|
+
* balance should be the one that was true when it was granted.
|
|
283
|
+
*/
|
|
284
|
+
async function applyApproval(tx, workspaceId, leaveRequestId, actorId) {
|
|
285
|
+
const request = await loadRequest(tx, workspaceId, leaveRequestId);
|
|
286
|
+
if (request.status === 'approved')
|
|
287
|
+
return;
|
|
288
|
+
const sim = await simulate(tx, workspaceId, request.personId, {
|
|
289
|
+
leaveTypeId: request.leaveTypeId,
|
|
290
|
+
startsOn: request.startsOn,
|
|
291
|
+
endsOn: request.endsOn,
|
|
292
|
+
startPart: request.startPart,
|
|
293
|
+
endPart: request.endPart,
|
|
294
|
+
hours: request.hours === null ? null : Number.parseFloat(request.hours),
|
|
295
|
+
});
|
|
296
|
+
await ledger.append(tx, workspaceId, {
|
|
297
|
+
personId: request.personId,
|
|
298
|
+
leaveTypeId: request.leaveTypeId,
|
|
299
|
+
kind: 'consumption',
|
|
300
|
+
amountMinutes: -sim.minutes,
|
|
301
|
+
effectiveOn: request.startsOn,
|
|
302
|
+
periodYear: yearOf(request.startsOn),
|
|
303
|
+
requestId: request.id,
|
|
304
|
+
reason: null,
|
|
305
|
+
createdBy: actorId,
|
|
306
|
+
});
|
|
307
|
+
await tx
|
|
308
|
+
.update(leaveRequestDays)
|
|
309
|
+
.set({ status: 'approved' })
|
|
310
|
+
.where(eq(leaveRequestDays.requestId, request.id));
|
|
311
|
+
await tx
|
|
312
|
+
.update(leaveRequests)
|
|
313
|
+
.set({
|
|
314
|
+
status: 'approved',
|
|
315
|
+
minutes: sim.minutes,
|
|
316
|
+
workingDays: String(sim.workingDays),
|
|
317
|
+
decidedAt: new Date(),
|
|
318
|
+
updatedAt: new Date(),
|
|
319
|
+
})
|
|
320
|
+
.where(eq(leaveRequests.id, request.id));
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Everything a punch needs about a person: their zone, and the schedule that shapes their day.
|
|
324
|
+
*
|
|
325
|
+
* The zone comes from the resolution ladder — their primary office unless they have an override —
|
|
326
|
+
* so a punch made on a business trip still counts towards the month they are employed in.
|
|
327
|
+
*
|
|
328
|
+
* Everything here is resolved **as of today**, which is what a punch is about. It is therefore
|
|
329
|
+
* not the place to answer a question about a past date: this used to hand out today's legal
|
|
330
|
+
* entity as well, and three callers applied it to business dates months back — so a person who
|
|
331
|
+
* transferred entity had a filed month recomputed against the one they are in now. `recomputeDay`
|
|
332
|
+
* asks that question of the day it is rebuilding.
|
|
333
|
+
*/
|
|
334
|
+
async function personContext(tx, workspaceId, personId) {
|
|
335
|
+
const today = todayIso();
|
|
336
|
+
const resolution = await resolve.forPerson(tx, workspaceId, personId, today);
|
|
337
|
+
const schedule = await attendance.scheduleFor(tx, workspaceId, personId, today);
|
|
338
|
+
return { timezone: resolution.timezone, schedule, resolution };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Apply an approved correction: write the proposed punches, void what they replace, rebuild.
|
|
342
|
+
*
|
|
343
|
+
* Nothing is edited. The original punch keeps its row and gains a pointer to what superseded it,
|
|
344
|
+
* so a corrected timesheet and an edited one stay distinguishable — which is the entire reason
|
|
345
|
+
* regularization exists rather than an update statement.
|
|
346
|
+
*/
|
|
347
|
+
async function applyRegularization(tx, workspaceId, regularizationId) {
|
|
348
|
+
const [row] = await tx
|
|
349
|
+
.select()
|
|
350
|
+
.from(regularizations)
|
|
351
|
+
.where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.id, regularizationId)))
|
|
352
|
+
.limit(1);
|
|
353
|
+
if (!row || row.status === 'approved')
|
|
354
|
+
return;
|
|
355
|
+
if (row.punchId)
|
|
356
|
+
await attendance.voidPunch(tx, workspaceId, row.punchId, 'Regularized', null);
|
|
357
|
+
const { timezone, schedule } = await personContext(tx, workspaceId, row.personId);
|
|
358
|
+
for (const proposal of row.proposed)
|
|
359
|
+
await tx.insert(punches).values({
|
|
360
|
+
id: uuidv7(),
|
|
361
|
+
workspaceId,
|
|
362
|
+
personId: row.personId,
|
|
363
|
+
direction: proposal.direction,
|
|
364
|
+
at: new Date(proposal.at),
|
|
365
|
+
businessDate: row.businessDate,
|
|
366
|
+
timezone,
|
|
367
|
+
method: 'manual',
|
|
368
|
+
trust: 'trusted',
|
|
369
|
+
note: `Regularization ${row.id}`,
|
|
370
|
+
});
|
|
371
|
+
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
|
|
372
|
+
await tx
|
|
373
|
+
.update(regularizations)
|
|
374
|
+
.set({ status: 'approved', appliedAt: new Date() })
|
|
375
|
+
.where(eq(regularizations.id, row.id));
|
|
376
|
+
}
|
|
377
|
+
/** A rejected request costs no balance and writes no punches; it just stops being live. */
|
|
378
|
+
async function applyLeaveDecision(tx, workspaceId, leaveRequestId, status, actorId) {
|
|
379
|
+
if (status === 'approved')
|
|
380
|
+
return applyApproval(tx, workspaceId, leaveRequestId, actorId);
|
|
381
|
+
await tx
|
|
382
|
+
.update(leaveRequests)
|
|
383
|
+
.set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
|
|
384
|
+
.where(eq(leaveRequests.id, leaveRequestId));
|
|
385
|
+
}
|
|
386
|
+
/** The same, for a correction. */
|
|
387
|
+
async function applyRegularizationDecision(tx, workspaceId, regularizationId, status) {
|
|
388
|
+
if (status === 'approved')
|
|
389
|
+
return applyRegularization(tx, workspaceId, regularizationId);
|
|
390
|
+
await tx
|
|
391
|
+
.update(regularizations)
|
|
392
|
+
.set({ status: 'rejected' })
|
|
393
|
+
.where(eq(regularizations.id, regularizationId));
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
loadRequest,
|
|
397
|
+
simulate,
|
|
398
|
+
applyApproval,
|
|
399
|
+
applyRegularization,
|
|
400
|
+
personContext,
|
|
401
|
+
/**
|
|
402
|
+
* The same two functions in the shape `ApprovalService` calls them in, keyed by `subjectType` —
|
|
403
|
+
* the only thing the engine knows about a subject.
|
|
404
|
+
*
|
|
405
|
+
* Parameterised by the actor because that is the one thing the two callers genuinely disagree
|
|
406
|
+
* about: a person approving leave is written onto the ledger entry as `created_by`, and a
|
|
407
|
+
* deadline running out is written as nobody. Passing the approver's id for a timeout would put
|
|
408
|
+
* a name against a decision that person did not make, which is exactly what
|
|
409
|
+
* `TIMEOUT_APPROVER_ID` exists to avoid one table over.
|
|
410
|
+
*/
|
|
411
|
+
appliersFor: (actorId) => ({
|
|
412
|
+
leave: (tx, workspaceId, request, status) => applyLeaveDecision(tx, workspaceId, request.subjectId, status, actorId),
|
|
413
|
+
regularization: (tx, workspaceId, request, status) => applyRegularizationDecision(tx, workspaceId, request.subjectId, status),
|
|
414
|
+
}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
87
417
|
/**
|
|
88
418
|
* The router.
|
|
89
419
|
*
|
|
@@ -103,9 +433,18 @@ export function implement_(kernel) {
|
|
|
103
433
|
const svc = new PeopleService(kernel);
|
|
104
434
|
const access = new HrAccessService(kernel);
|
|
105
435
|
const ledger = new LedgerService();
|
|
106
|
-
const approvals = new ApprovalService(kernel);
|
|
107
436
|
const policySvc = new PolicyService(resolve);
|
|
108
437
|
const attendance = new AttendanceService(resolve, policySvc);
|
|
438
|
+
const subjects = hrSubjects({ resolve, ledger, attendance });
|
|
439
|
+
const { applyApproval, applyRegularization, loadRequest, personContext, simulate } = subjects;
|
|
440
|
+
/**
|
|
441
|
+
* The engine gets the appliers here as well as in `jobs.ts`, so the two constructions read the
|
|
442
|
+
* same and stay the same. A sweep started from a request is still nobody's decision, which is why
|
|
443
|
+
* this one is built for no actor — the per-request actor arrives at `decide` below.
|
|
444
|
+
*/
|
|
445
|
+
const approvals = new ApprovalService(kernel, subjects.appliersFor(null));
|
|
446
|
+
const privacy = new PrivacyService();
|
|
447
|
+
const audit = new HrAuditService(kernel, access);
|
|
109
448
|
const db = kernel.database;
|
|
110
449
|
const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
|
|
111
450
|
const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
|
|
@@ -182,9 +521,12 @@ export function implement_(kernel) {
|
|
|
182
521
|
.where(and(eq(officeAssignments.workspaceId, input.workspaceId), inArray(officeAssignments.personId, rows.map((r) => r.id)), eq(officeAssignments.isPrimary, true), isNull(officeAssignments.effectiveTo)))
|
|
183
522
|
: [];
|
|
184
523
|
const officeBy = new Map(assignments.map((a) => [a.personId, a]));
|
|
524
|
+
const hidden = await sensitiveCustomKeys(tx, input.workspaceId, context.principal);
|
|
525
|
+
const own = hidden.size ? await access.personIdOf(tx, input.workspaceId, context.principal) : null;
|
|
185
526
|
return {
|
|
186
527
|
items: rows.map((r) => forViewer({
|
|
187
528
|
...PeopleService.toPerson(r),
|
|
529
|
+
custom: r.id === own ? (r.custom ?? {}) : stripSensitiveCustom(r.custom ?? {}, hidden),
|
|
188
530
|
// Spreading into a fresh literal drops the branded WorkspaceId that flowed through
|
|
189
531
|
// `toPerson`, so it is restored rather than widened to `string`.
|
|
190
532
|
workspaceId: r.workspaceId,
|
|
@@ -204,7 +546,13 @@ export function implement_(kernel) {
|
|
|
204
546
|
*/
|
|
205
547
|
get: scoped.people.get.use(requires('hr.person.view')).handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
206
548
|
const visible = visibleSet(await access.visiblePersonIds(tx, input.workspaceId, context.principal));
|
|
207
|
-
|
|
549
|
+
const person = PeopleService.toPerson(await svc.load(tx, input.workspaceId, input.personId));
|
|
550
|
+
const hidden = await sensitiveCustomKeys(tx, input.workspaceId, context.principal);
|
|
551
|
+
const own = hidden.size ? await access.personIdOf(tx, input.workspaceId, context.principal) : null;
|
|
552
|
+
return forViewer({
|
|
553
|
+
...person,
|
|
554
|
+
custom: person.id === own ? person.custom : stripSensitiveCustom(person.custom, hidden),
|
|
555
|
+
}, visible);
|
|
208
556
|
})),
|
|
209
557
|
/**
|
|
210
558
|
* No permission check: everybody may read their own record, and a permission nobody can lack
|
|
@@ -2036,10 +2384,17 @@ export function implement_(kernel) {
|
|
|
2036
2384
|
// Same reason as leave: the approvers are told after this commits, never inside it.
|
|
2037
2385
|
return {
|
|
2038
2386
|
row: fresh,
|
|
2039
|
-
approval: {
|
|
2387
|
+
approval: {
|
|
2388
|
+
requestId: raised.request.id,
|
|
2389
|
+
approverIds: raised.firstStepApprovers,
|
|
2390
|
+
userIds: await accountsOf(tx, input.workspaceId, raised.firstStepApprovers),
|
|
2391
|
+
summary: raised.request.summary,
|
|
2392
|
+
summaryParams: raised.request.summaryParams,
|
|
2393
|
+
actorId: raised.request.requestedBy,
|
|
2394
|
+
},
|
|
2040
2395
|
};
|
|
2041
2396
|
});
|
|
2042
|
-
if (filed.approval.approverIds.length)
|
|
2397
|
+
if (filed.approval.approverIds.length) {
|
|
2043
2398
|
await kernel.emit(hrEvents.approvalRequested, {
|
|
2044
2399
|
requestId: filed.approval.requestId,
|
|
2045
2400
|
workspaceId: input.workspaceId,
|
|
@@ -2047,6 +2402,16 @@ export function implement_(kernel) {
|
|
|
2047
2402
|
subjectId: filed.row.id,
|
|
2048
2403
|
approverIds: filed.approval.approverIds,
|
|
2049
2404
|
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
2405
|
+
await notifyApprovers({
|
|
2406
|
+
workspaceId: input.workspaceId,
|
|
2407
|
+
requestId: filed.approval.requestId,
|
|
2408
|
+
subjectType: 'regularization',
|
|
2409
|
+
summary: filed.approval.summary,
|
|
2410
|
+
summaryParams: filed.approval.summaryParams,
|
|
2411
|
+
userIds: filed.approval.userIds,
|
|
2412
|
+
actorId: filed.approval.actorId,
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2050
2415
|
await changed(input.workspaceId, 'regularization', filed.row.id, 'created');
|
|
2051
2416
|
return toRegularization(filed.row);
|
|
2052
2417
|
}),
|
|
@@ -2357,7 +2722,17 @@ export function implement_(kernel) {
|
|
|
2357
2722
|
request: fresh,
|
|
2358
2723
|
personId,
|
|
2359
2724
|
replay: false,
|
|
2360
|
-
approval: {
|
|
2725
|
+
approval: {
|
|
2726
|
+
requestId: raised.request.id,
|
|
2727
|
+
approverIds: raised.firstStepApprovers,
|
|
2728
|
+
// Resolved here because it needs `tx`, delivered outside because a notification
|
|
2729
|
+
// cannot be rolled back. Person ids are HR's identity and accounts are core's, so
|
|
2730
|
+
// the translation happens once, on the way out.
|
|
2731
|
+
userIds: await accountsOf(tx, input.workspaceId, raised.firstStepApprovers),
|
|
2732
|
+
summary: raised.request.summary,
|
|
2733
|
+
summaryParams: raised.request.summaryParams,
|
|
2734
|
+
actorId: raised.request.requestedBy,
|
|
2735
|
+
},
|
|
2361
2736
|
};
|
|
2362
2737
|
});
|
|
2363
2738
|
const result = await filing.catch(async (err) => {
|
|
@@ -2389,7 +2764,7 @@ export function implement_(kernel) {
|
|
|
2389
2764
|
// the people the *first* step is on. Nothing for a chain that resolved to nobody — that
|
|
2390
2765
|
// was approved on the way in and is not waiting on anyone. The ids are person ids, the
|
|
2391
2766
|
// same identity the rest of `hr.*` carries.
|
|
2392
|
-
if (result.approval.approverIds.length)
|
|
2767
|
+
if (result.approval.approverIds.length) {
|
|
2393
2768
|
await kernel.emit(hrEvents.approvalRequested, {
|
|
2394
2769
|
requestId: result.approval.requestId,
|
|
2395
2770
|
workspaceId: input.workspaceId,
|
|
@@ -2397,6 +2772,18 @@ export function implement_(kernel) {
|
|
|
2397
2772
|
subjectId: result.request.id,
|
|
2398
2773
|
approverIds: result.approval.approverIds,
|
|
2399
2774
|
}, { workspaceId: input.workspaceId, actorId: context.principal.userId });
|
|
2775
|
+
// And then the approvers themselves. The event is for other modules; this is for the
|
|
2776
|
+
// people whose signature the request is now waiting on.
|
|
2777
|
+
await notifyApprovers({
|
|
2778
|
+
workspaceId: input.workspaceId,
|
|
2779
|
+
requestId: result.approval.requestId,
|
|
2780
|
+
subjectType: 'leave',
|
|
2781
|
+
summary: result.approval.summary,
|
|
2782
|
+
summaryParams: result.approval.summaryParams,
|
|
2783
|
+
userIds: result.approval.userIds,
|
|
2784
|
+
actorId: result.approval.actorId,
|
|
2785
|
+
});
|
|
2786
|
+
}
|
|
2400
2787
|
await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
|
|
2401
2788
|
return toLeaveRequest(result.request);
|
|
2402
2789
|
}),
|
|
@@ -2547,24 +2934,15 @@ export function implement_(kernel) {
|
|
|
2547
2934
|
// The approval engine knows nothing about leave. Applying the decision to the subject is
|
|
2548
2935
|
// the caller's job, which is what keeps the engine reusable for regularization and
|
|
2549
2936
|
// overtime later.
|
|
2937
|
+
//
|
|
2938
|
+
// Through the same appliers the timeout sweep is given, rather than a branch of its own:
|
|
2939
|
+
// this used to be a `switch` on `subjectType` here and nothing at all in the job, which is
|
|
2940
|
+
// how a deadline could approve a request and leave its leave unbooked. One table of
|
|
2941
|
+
// subject types, and adding overtime means adding a line to it and nothing here.
|
|
2550
2942
|
const request = result.request;
|
|
2551
|
-
if (
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
else if (result.status === 'rejected')
|
|
2555
|
-
await tx
|
|
2556
|
-
.update(leaveRequests)
|
|
2557
|
-
.set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
|
|
2558
|
-
.where(eq(leaveRequests.id, request.subjectId));
|
|
2559
|
-
}
|
|
2560
|
-
else if (request.subjectType === 'regularization') {
|
|
2561
|
-
if (result.status === 'approved')
|
|
2562
|
-
await applyRegularization(tx, input.workspaceId, request.subjectId);
|
|
2563
|
-
else if (result.status === 'rejected')
|
|
2564
|
-
await tx
|
|
2565
|
-
.update(regularizations)
|
|
2566
|
-
.set({ status: 'rejected' })
|
|
2567
|
-
.where(eq(regularizations.id, request.subjectId));
|
|
2943
|
+
if (result.status !== 'pending') {
|
|
2944
|
+
const apply = subjects.appliersFor(context.principal.userId ?? null)[request.subjectType];
|
|
2945
|
+
await apply?.(tx, input.workspaceId, request, result.status);
|
|
2568
2946
|
}
|
|
2569
2947
|
const [fresh] = await tx
|
|
2570
2948
|
.select()
|
|
@@ -2805,28 +3183,313 @@ export function implement_(kernel) {
|
|
|
2805
3183
|
return { ok: true };
|
|
2806
3184
|
}),
|
|
2807
3185
|
},
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
3186
|
+
// ================================================================= privacy
|
|
3187
|
+
/**
|
|
3188
|
+
* Subject access, erasure and retention.
|
|
3189
|
+
*
|
|
3190
|
+
* `hr.privacy.manage` gates all of it except reading your own access log, and it is granted to
|
|
3191
|
+
* nobody by default — so on a fresh workspace only an owner, who passes every check, can reach
|
|
3192
|
+
* any of this. That is the intended starting position: whether anybody below an owner may export
|
|
3193
|
+
* or erase a colleague is a decision the workspace makes deliberately, in the role editor.
|
|
3194
|
+
*/
|
|
3195
|
+
privacy: {
|
|
3196
|
+
/**
|
|
3197
|
+
* Everything HR holds about one person.
|
|
3198
|
+
*
|
|
3199
|
+
* Two transactions rather than one, and on purpose. `PeopleService.readSensitive` opens its
|
|
3200
|
+
* own so the decrypt and the `sensitive_access_log` row it writes are atomic with each other —
|
|
3201
|
+
* that atomicity is the point of the log and it must not be widened into a transaction that
|
|
3202
|
+
* also holds several thousand rows of punches open. The bundle's own reads then run in a
|
|
3203
|
+
* second transaction. The consequence is that the two halves are a moment apart, which for a
|
|
3204
|
+
* subject-access snapshot is not a property anybody depends on.
|
|
3205
|
+
*
|
|
3206
|
+
* The access log is read **after** the export's own row is written, so a subject's bundle
|
|
3207
|
+
* always contains the read that produced it. A bundle that could not account for its own
|
|
3208
|
+
* existence is the first hole somebody would find in it.
|
|
3209
|
+
*/
|
|
3210
|
+
subjectAccess: scoped.privacy.subjectAccess
|
|
3211
|
+
.use(requires('hr.privacy.manage'))
|
|
3212
|
+
.handler(async ({ input, context }) => {
|
|
3213
|
+
const { workspaceId, personId } = input;
|
|
3214
|
+
// Before anything else: this both proves the person exists and records the disclosure.
|
|
3215
|
+
// Refusing here means nothing was decrypted and nothing was assembled.
|
|
3216
|
+
const sensitive = await svc.readSensitive({
|
|
3217
|
+
workspaceId,
|
|
3218
|
+
personId,
|
|
3219
|
+
principal: context.principal,
|
|
3220
|
+
via: 'export',
|
|
3221
|
+
purpose: input.purpose ?? null,
|
|
3222
|
+
});
|
|
3223
|
+
return db.withWorkspace(workspaceId, async (tx) => {
|
|
3224
|
+
const person = await svc.load(tx, workspaceId, personId);
|
|
3225
|
+
const data = await privacy.subjectAccess(tx, workspaceId, personId);
|
|
3226
|
+
// Resolved here rather than left to the client, and included whatever the
|
|
3227
|
+
// `leave_accrual` capability says: "why is my balance this number" is the commonest
|
|
3228
|
+
// follow-up to a subject-access request, and it is the subject's own data either way.
|
|
3229
|
+
const kinds = ['accrual', 'carry_forward', 'overtime', 'rounding', 'working_time'];
|
|
3230
|
+
const policiesInForce = [];
|
|
3231
|
+
for (const kind of kinds)
|
|
3232
|
+
policiesInForce.push(await policySvc.forPerson(tx, workspaceId, personId, kind, todayIso()));
|
|
3233
|
+
const decisionsOf = (stepId) => data.approvals.stepDecisions
|
|
3234
|
+
.filter((d) => d.stepId === stepId)
|
|
3235
|
+
.map((d) => ({ ...d, decision: d.decision, at: d.at.toISOString() }));
|
|
3236
|
+
const toStep = (s) => ({
|
|
3237
|
+
...s,
|
|
3238
|
+
mode: s.mode,
|
|
3239
|
+
status: s.status,
|
|
3240
|
+
dueAt: s.dueAt?.toISOString() ?? null,
|
|
3241
|
+
escalatedAt: s.escalatedAt?.toISOString() ?? null,
|
|
3242
|
+
decisions: decisionsOf(s.id),
|
|
3243
|
+
});
|
|
3244
|
+
return {
|
|
3245
|
+
manifest: {
|
|
3246
|
+
workspaceId,
|
|
3247
|
+
personId,
|
|
3248
|
+
generatedAt: new Date().toISOString(),
|
|
3249
|
+
generatedBy: context.principal.userId ?? null,
|
|
3250
|
+
moduleVersion: packageVersion(import.meta.url),
|
|
3251
|
+
truncated: data.truncated,
|
|
3252
|
+
// Stated rather than left to be noticed. HR holds the metadata and the file id for
|
|
3253
|
+
// every document; the bytes live in core's storage and `core.files.get` signs one
|
|
3254
|
+
// download at a time, so a module cannot put them in a bundle. Naming the omission
|
|
3255
|
+
// is the difference between an incomplete export and a dishonest one.
|
|
3256
|
+
excluded: [{ section: 'documents.contents', reason: 'fileContentsNotExportable' }],
|
|
3257
|
+
},
|
|
3258
|
+
// The whole row, never `forViewer`: the four personnel fields it withholds from a
|
|
3259
|
+
// reader are the subject's own.
|
|
3260
|
+
person: PeopleService.toPerson(person),
|
|
3261
|
+
sensitive,
|
|
3262
|
+
employment: data.employment.map(PeopleService.toEmployment),
|
|
3263
|
+
offices: data.offices.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
|
|
3264
|
+
history: data.history.map((r) => ({
|
|
3265
|
+
id: r.id,
|
|
3266
|
+
field: r.field,
|
|
3267
|
+
from: r.from ?? null,
|
|
3268
|
+
to: r.to ?? null,
|
|
3269
|
+
at: r.at.toISOString(),
|
|
3270
|
+
actorId: r.actorId,
|
|
3271
|
+
source: r.source,
|
|
3272
|
+
})),
|
|
3273
|
+
documents: data.documents.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
|
|
3274
|
+
leave: {
|
|
3275
|
+
types: data.leave.types.map(toLeaveType),
|
|
3276
|
+
requests: data.leave.requests.map(toLeaveRequest),
|
|
3277
|
+
days: data.leave.days.map((r) => ({
|
|
3278
|
+
id: r.id,
|
|
3279
|
+
requestId: r.requestId,
|
|
3280
|
+
date: r.date,
|
|
3281
|
+
fraction: Number.parseFloat(r.fraction),
|
|
3282
|
+
counted: r.counted,
|
|
3283
|
+
status: r.status,
|
|
3284
|
+
})),
|
|
3285
|
+
ledger: data.leave.ledger.map(toLedgerEntry),
|
|
3286
|
+
closingBalanceMinutes: closingBalance(data.leave.ledger),
|
|
3287
|
+
},
|
|
3288
|
+
attendance: {
|
|
3289
|
+
punches: data.attendance.punches.map(toPunch),
|
|
3290
|
+
days: data.attendance.days.map(toAttendanceDay),
|
|
3291
|
+
},
|
|
3292
|
+
regularizations: data.regularizations.map(toRegularization),
|
|
3293
|
+
approvals: {
|
|
3294
|
+
raised: data.approvals.raised.map((r) => ({
|
|
3295
|
+
...r,
|
|
3296
|
+
subjectType: r.subjectType,
|
|
3297
|
+
status: r.status,
|
|
3298
|
+
// The requester is the subject of this bundle, so the name is already loaded —
|
|
3299
|
+
// and after an erasure it is the tombstone token, which is the right answer.
|
|
3300
|
+
requesterName: person.displayName,
|
|
3301
|
+
requestedAt: r.requestedAt.toISOString(),
|
|
3302
|
+
decidedAt: r.decidedAt?.toISOString() ?? null,
|
|
3303
|
+
steps: data.approvals.raisedSteps.filter((s) => s.requestId === r.id).map(toStep),
|
|
3304
|
+
})),
|
|
3305
|
+
approverOn: data.approvals.approverOn.map(toStep),
|
|
3306
|
+
decisions: data.approvals.decisions.map((d) => ({
|
|
3307
|
+
...d,
|
|
3308
|
+
decision: d.decision,
|
|
3309
|
+
at: d.at.toISOString(),
|
|
3310
|
+
})),
|
|
3311
|
+
},
|
|
3312
|
+
delegations: {
|
|
3313
|
+
given: data.delegations.given.map(toDelegation),
|
|
3314
|
+
received: data.delegations.received.map(toDelegation),
|
|
3315
|
+
},
|
|
3316
|
+
policiesInForce,
|
|
3317
|
+
accessLog: data.accessLog.map(HrAuditService.toEntry),
|
|
3318
|
+
};
|
|
3319
|
+
});
|
|
3320
|
+
}),
|
|
3321
|
+
/**
|
|
3322
|
+
* Redact a person, or say what redacting them would do.
|
|
3323
|
+
*
|
|
3324
|
+
* One transaction for the whole erasure: a half-run erasure is worse than a refused one, and a
|
|
3325
|
+
* partial one is not something anybody could tell had happened. It is replayable as well as
|
|
3326
|
+
* atomic — every step matches only rows that still have something to clear — so the recovery
|
|
3327
|
+
* from any failure is to run it again.
|
|
3328
|
+
*
|
|
3329
|
+
* The dry run takes the same transaction and rolls nothing back because it writes nothing; it
|
|
3330
|
+
* runs the identical predicates, which is what stops a preview drifting from the act.
|
|
3331
|
+
*/
|
|
3332
|
+
erase: scoped.privacy.erase.use(requires('hr.privacy.manage')).handler(async ({ input, context }) => {
|
|
3333
|
+
const result = await db.withWorkspace(input.workspaceId, (tx) => privacy.erase(tx, {
|
|
3334
|
+
workspaceId: input.workspaceId,
|
|
3335
|
+
personId: input.personId,
|
|
3336
|
+
dryRun: input.dryRun,
|
|
3337
|
+
reason: input.reason ?? null,
|
|
3338
|
+
keepNationalIdForAudit: input.keepNationalIdForAudit,
|
|
3339
|
+
actorUserId: context.principal.userId ?? null,
|
|
3340
|
+
}));
|
|
3341
|
+
// Only a real run moved anything. A dry run that pushed a realtime change would blank the
|
|
3342
|
+
// person's card on every open screen in the workspace for a preview nobody committed.
|
|
3343
|
+
if (!input.dryRun)
|
|
3344
|
+
await changed(input.workspaceId, 'person', input.personId, 'updated');
|
|
3345
|
+
return {
|
|
3346
|
+
workspaceId: input.workspaceId,
|
|
3347
|
+
personId: input.personId,
|
|
3348
|
+
dryRun: input.dryRun,
|
|
3349
|
+
erasedAt: result.erasedAt?.toISOString() ?? null,
|
|
3350
|
+
displayName: result.displayName,
|
|
3351
|
+
redacted: result.redacted,
|
|
3352
|
+
kept: result.kept,
|
|
3353
|
+
caveats: result.caveats,
|
|
3354
|
+
filesRemaining: result.filesRemaining,
|
|
3355
|
+
};
|
|
3356
|
+
}),
|
|
3357
|
+
accessLog: {
|
|
3358
|
+
/**
|
|
3359
|
+
* Who read this person's sensitive fields.
|
|
3360
|
+
*
|
|
3361
|
+
* **No `requires()`, and that is the whole design of this procedure.** Reading your own
|
|
3362
|
+
* access log is a thing nobody may lack — a grantable key here could only ever be one
|
|
3363
|
+
* somebody could be *denied*, and "you may not see who has been looking at your bank
|
|
3364
|
+
* details" is not a state this product should be able to express. It is in
|
|
3365
|
+
* `module.test.ts`'s `SELF_SERVICE` allowlist for that reason, beside `people.me`.
|
|
3366
|
+
*
|
|
3367
|
+
* The permission check is in the handler because it depends on the arguments: any
|
|
3368
|
+
* `personId` that is not the caller's own, and any `actorUserId` at all. The second is not
|
|
3369
|
+
* the smaller case — "what has this account been looking at" is an investigation into a
|
|
3370
|
+
* colleague, and it is the query that makes this log a thing to be careful with rather than
|
|
3371
|
+
* only a thing to be reassured by.
|
|
3372
|
+
*/
|
|
3373
|
+
list: scoped.privacy.accessLog.list.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
3374
|
+
const own = await access.personIdOf(tx, input.workspaceId, context.principal);
|
|
3375
|
+
const aboutSomebodyElse = input.personId !== undefined && input.personId !== own;
|
|
3376
|
+
const isInvestigation = input.actorUserId !== undefined;
|
|
3377
|
+
if (aboutSomebodyElse || isInvestigation)
|
|
3378
|
+
await kernel.authz.require(context.principal, 'hr.privacy.manage', {
|
|
3379
|
+
kind: 'workspace',
|
|
3380
|
+
workspaceId: input.workspaceId,
|
|
3381
|
+
});
|
|
3382
|
+
// A member who was never made a person has no log of their own and no permission to
|
|
3383
|
+
// read anybody's. Refusing beats returning an empty page, which reads as "nobody has
|
|
3384
|
+
// ever looked at your record" — an answer, and the wrong one.
|
|
3385
|
+
if (!aboutSomebodyElse && !isInvestigation && !own)
|
|
3386
|
+
throw KernError.notFound('Person');
|
|
3387
|
+
const cursor = decodeCursor(input.cursor);
|
|
3388
|
+
const rows = await audit.list(tx, {
|
|
3389
|
+
workspaceId: input.workspaceId,
|
|
3390
|
+
personId: input.personId ?? own ?? undefined,
|
|
3391
|
+
actorUserId: input.actorUserId,
|
|
3392
|
+
limit: input.limit,
|
|
3393
|
+
after: cursor ? after(accessLogSort.at, accessLogSort.id, 'desc', cursor) : undefined,
|
|
3394
|
+
});
|
|
3395
|
+
// `atText`, never the `Date`: one export writes its rows in a single insert, so they
|
|
3396
|
+
// share `now()` to the microsecond, and a millisecond-truncated cursor drops every row
|
|
3397
|
+
// that ties with the last one on the page.
|
|
3398
|
+
const { items, nextCursor } = paginate(rows, input.limit, (r) => [r.atText, r.id]);
|
|
3399
|
+
// No `total`. Counting an append-only log a subject scrolls through costs a second scan
|
|
3400
|
+
// of the same rows to answer a question nobody asked, and `page()` makes it optional
|
|
3401
|
+
// precisely so a list can decline.
|
|
3402
|
+
return { items: items.map(HrAuditService.toEntry), nextCursor };
|
|
3403
|
+
})),
|
|
3404
|
+
},
|
|
3405
|
+
retention: {
|
|
3406
|
+
/**
|
|
3407
|
+
* The horizons, and what is already past them.
|
|
3408
|
+
*
|
|
3409
|
+
* `sweepEnabled` is a literal `false` in the contract, and it says the thing this feature
|
|
3410
|
+
* must not imply: nothing in HR deletes on a timer. The horizons are read here, to count
|
|
3411
|
+
* what has passed one, and by `privacy.erase`, to say under which horizon each surviving
|
|
3412
|
+
* class was kept. An unattended job that prunes personnel records is the one act in this
|
|
3413
|
+
* module that cannot be undone by re-running anything, so it ships off, with a dry run and
|
|
3414
|
+
* a per-run report naming every person it touched — and until it exists, saying so in the
|
|
3415
|
+
* response is what keeps this screen from promising it.
|
|
3416
|
+
*/
|
|
3417
|
+
get: scoped.privacy.retention.get.use(requires('hr.privacy.manage')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
3418
|
+
const { retention, updatedAt, updatedBy } = await privacy.retention(tx, input.workspaceId);
|
|
3419
|
+
const counts = input.withCounts
|
|
3420
|
+
? await privacy.retentionCounts(tx, input.workspaceId, retention)
|
|
3421
|
+
: null;
|
|
3422
|
+
return {
|
|
3423
|
+
workspaceId: input.workspaceId,
|
|
3424
|
+
classes: RETENTION_CLASSES.map((cls) => ({
|
|
3425
|
+
class: cls,
|
|
3426
|
+
days: retention[cls],
|
|
3427
|
+
dueNow: counts?.[cls] ?? null,
|
|
3428
|
+
})),
|
|
3429
|
+
updatedAt: updatedAt?.toISOString() ?? null,
|
|
3430
|
+
updatedBy,
|
|
3431
|
+
sweepEnabled: false,
|
|
3432
|
+
};
|
|
3433
|
+
})),
|
|
3434
|
+
set: scoped.privacy.retention.set.use(requires('hr.privacy.manage')).handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
3435
|
+
const { retention, updatedAt, updatedBy } = await privacy.setRetention(tx, input.workspaceId, input.retention, context.principal.userId ?? null);
|
|
3436
|
+
// The counts are not recomputed on a write: a screen that has just changed a horizon
|
|
3437
|
+
// asks for them again, and doing eight counts inside the write transaction would hold
|
|
3438
|
+
// it open across the most expensive queries in this file.
|
|
3439
|
+
return {
|
|
3440
|
+
workspaceId: input.workspaceId,
|
|
3441
|
+
classes: RETENTION_CLASSES.map((cls) => ({
|
|
3442
|
+
class: cls,
|
|
3443
|
+
days: retention[cls],
|
|
3444
|
+
dueNow: null,
|
|
3445
|
+
})),
|
|
3446
|
+
updatedAt: updatedAt?.toISOString() ?? null,
|
|
3447
|
+
updatedBy,
|
|
3448
|
+
sweepEnabled: false,
|
|
3449
|
+
};
|
|
3450
|
+
})),
|
|
3451
|
+
},
|
|
3452
|
+
},
|
|
3453
|
+
});
|
|
3454
|
+
// ------------------------------------------------------------------ helpers
|
|
3455
|
+
// Closures over `kernel` and `db`, kept at the bottom so the router above reads as a list of
|
|
3456
|
+
// procedures rather than a list of procedures interrupted by plumbing.
|
|
3457
|
+
/**
|
|
3458
|
+
* The `people.custom` keys this reader may not be shown.
|
|
3459
|
+
*
|
|
3460
|
+
* `custom_field_defs.sensitive` has been declared, stored, editable and documented as "needs
|
|
3461
|
+
* `hr.person.view_sensitive`, like a national identity number" since the day custom fields
|
|
3462
|
+
* shipped — and until now **nothing read it**. `toPerson` returns `custom` whole and `forViewer`
|
|
3463
|
+
* narrows only the four personnel fields, so a field an administrator deliberately marked
|
|
3464
|
+
* sensitive went to every holder of `hr.person.view`, which is a `member` default. That is the
|
|
3465
|
+
* same defect as a permission key nothing asks about, one level down, and it is why the fix lands
|
|
3466
|
+
* here rather than waiting for a screen.
|
|
3467
|
+
*
|
|
3468
|
+
* Empty for a reader who holds the permission, and empty for a workspace with no sensitive fields
|
|
3469
|
+
* — which is the ordinary case and the one that must not pay for this. Archived definitions are
|
|
3470
|
+
* still counted: `fields.archive` deliberately leaves the values in `people.custom`, so an
|
|
3471
|
+
* archived sensitive field is a sensitive value with its guard removed.
|
|
3472
|
+
*
|
|
3473
|
+
* A person always sees their own, which is the same rule `people.me` follows: a permission you
|
|
3474
|
+
* would need to read your own record is one nobody may lack.
|
|
3475
|
+
*/
|
|
3476
|
+
async function sensitiveCustomKeys(tx, workspaceId, principal) {
|
|
3477
|
+
if (await kernel.authz.can(principal, 'hr.person.view_sensitive', { kind: 'workspace', workspaceId }))
|
|
3478
|
+
return NO_HIDDEN_FIELDS;
|
|
3479
|
+
const rows = await tx
|
|
3480
|
+
.select({ key: customFieldDefs.key })
|
|
3481
|
+
.from(customFieldDefs)
|
|
3482
|
+
.where(and(eq(customFieldDefs.workspaceId, workspaceId), eq(customFieldDefs.sensitive, true)));
|
|
3483
|
+
return rows.length ? new Set(rows.map((r) => r.key)) : NO_HIDDEN_FIELDS;
|
|
2821
3484
|
}
|
|
2822
|
-
async function
|
|
3485
|
+
async function loadOffice(tx, input) {
|
|
2823
3486
|
const [row] = await tx
|
|
2824
3487
|
.select()
|
|
2825
|
-
.from(
|
|
2826
|
-
.where(and(eq(
|
|
3488
|
+
.from(offices)
|
|
3489
|
+
.where(and(eq(offices.workspaceId, input.workspaceId), eq(offices.id, input.officeId)))
|
|
2827
3490
|
.limit(1);
|
|
2828
3491
|
if (!row)
|
|
2829
|
-
throw KernError.notFound('
|
|
3492
|
+
throw KernError.notFound('Office');
|
|
2830
3493
|
return row;
|
|
2831
3494
|
}
|
|
2832
3495
|
/** The workspace's calendar for a country pack, created on first use so offices can share one. */
|
|
@@ -2892,51 +3555,6 @@ export function implement_(kernel) {
|
|
|
2892
3555
|
}
|
|
2893
3556
|
throw KernError.badRequest('Calendars may only be built on three levels.');
|
|
2894
3557
|
}
|
|
2895
|
-
/** The chain nearest-first: this calendar, then whatever it extends. */
|
|
2896
|
-
async function calendarChain(tx, workspaceId, calendarId) {
|
|
2897
|
-
const chain = [];
|
|
2898
|
-
let cursor = calendarId;
|
|
2899
|
-
for (let depth = 0; depth < 4 && cursor; depth++) {
|
|
2900
|
-
const row = await loadCalendar(tx, workspaceId, cursor);
|
|
2901
|
-
chain.push(row);
|
|
2902
|
-
cursor = row.extendsId;
|
|
2903
|
-
}
|
|
2904
|
-
return chain;
|
|
2905
|
-
}
|
|
2906
|
-
/**
|
|
2907
|
-
* The composed calendar over a range: this calendar's days over the ones it extends.
|
|
2908
|
-
*
|
|
2909
|
-
* Nearest wins per date and kind, and a day that shadows one from a calendar further down is
|
|
2910
|
-
* marked `overrides` so the editor can show what it is replacing — which is what makes "we work
|
|
2911
|
-
* through this national holiday" legible rather than looking like a missing holiday.
|
|
2912
|
-
*/
|
|
2913
|
-
async function composedDays(tx, workspaceId, calendarId, from, to) {
|
|
2914
|
-
const chain = await calendarChain(tx, workspaceId, calendarId);
|
|
2915
|
-
const rows = await tx
|
|
2916
|
-
.select()
|
|
2917
|
-
.from(calendarDays)
|
|
2918
|
-
.where(and(eq(calendarDays.workspaceId, workspaceId), inArray(calendarDays.calendarId, chain.map((c) => c.id)), gte(calendarDays.date, from), lte(calendarDays.date, to)));
|
|
2919
|
-
const nameById = new Map(chain.map((c) => [c.id, c.name]));
|
|
2920
|
-
const seen = new Map();
|
|
2921
|
-
const datesFromNearest = new Set();
|
|
2922
|
-
for (const cal of chain) {
|
|
2923
|
-
for (const row of rows.filter((r) => r.calendarId === cal.id)) {
|
|
2924
|
-
const key = `${row.date}:${row.kind}`;
|
|
2925
|
-
if (seen.has(key))
|
|
2926
|
-
continue;
|
|
2927
|
-
const overrides = cal.id !== calendarId ? false : datesFromNearest.has(row.date);
|
|
2928
|
-
seen.set(key, toResolvedDay(row, cal.id, nameById.get(cal.id) ?? '', overrides));
|
|
2929
|
-
if (cal.id === calendarId)
|
|
2930
|
-
datesFromNearest.add(row.date);
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
// Second pass: a nearest-calendar day covering a date the base also has *is* an override, and
|
|
2934
|
-
// the first pass cannot know that until the base has been read.
|
|
2935
|
-
const baseDates = new Set(rows.filter((r) => r.calendarId !== calendarId).map((r) => r.date));
|
|
2936
|
-
return [...seen.values()]
|
|
2937
|
-
.map((d) => ({ ...d, overrides: d.fromCalendarId === calendarId && baseDates.has(d.date) }))
|
|
2938
|
-
.sort((a, b) => a.date.localeCompare(b.date));
|
|
2939
|
-
}
|
|
2940
3558
|
/**
|
|
2941
3559
|
* What applying a pack would do — and, just as importantly, what it would leave alone.
|
|
2942
3560
|
*
|
|
@@ -3072,154 +3690,75 @@ export function implement_(kernel) {
|
|
|
3072
3690
|
.limit(1);
|
|
3073
3691
|
return row;
|
|
3074
3692
|
}
|
|
3075
|
-
async function loadRequest(tx, workspaceId, requestId) {
|
|
3076
|
-
const [row] = await tx
|
|
3077
|
-
.select()
|
|
3078
|
-
.from(leaveRequests)
|
|
3079
|
-
.where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
|
|
3080
|
-
.limit(1);
|
|
3081
|
-
if (!row)
|
|
3082
|
-
throw KernError.notFound('Leave request');
|
|
3083
|
-
return row;
|
|
3084
|
-
}
|
|
3085
3693
|
/**
|
|
3086
|
-
*
|
|
3694
|
+
* The Kern accounts behind a set of people.
|
|
3087
3695
|
*
|
|
3088
|
-
*
|
|
3089
|
-
*
|
|
3090
|
-
*
|
|
3696
|
+
* An employee need not have an account, and one removed from the workspace has had the link
|
|
3697
|
+
* cleared on purpose by the `core.member.removed` subscription. Both are "nothing to deliver",
|
|
3698
|
+
* not an error — the same rule `sweepTimeouts` applies to the people it has to reach.
|
|
3091
3699
|
*/
|
|
3092
|
-
async function
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
.
|
|
3098
|
-
.
|
|
3099
|
-
|
|
3100
|
-
.limit(1);
|
|
3101
|
-
if (!type)
|
|
3102
|
-
throw KernError.notFound('Leave type');
|
|
3103
|
-
if (type.archivedAt)
|
|
3104
|
-
blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
|
|
3105
|
-
const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
|
|
3106
|
-
const calendarDaysInRange = resolution.calendarId
|
|
3107
|
-
? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
|
|
3108
|
-
: [];
|
|
3109
|
-
const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
|
|
3110
|
-
? calendarDaysInRange.map((d) => ({
|
|
3111
|
-
date: d.date,
|
|
3112
|
-
name: d.name,
|
|
3113
|
-
workingFraction: d.workingFraction,
|
|
3114
|
-
}))
|
|
3115
|
-
: []);
|
|
3116
|
-
// Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
|
|
3117
|
-
// holiday still costs nothing rather than costing half of nothing.
|
|
3118
|
-
const days = results.map((r) => {
|
|
3119
|
-
let fraction = r.fraction;
|
|
3120
|
-
if (r.date === input.startsOn && input.startPart === 'afternoon')
|
|
3121
|
-
fraction = Math.min(fraction, 0.5);
|
|
3122
|
-
if (r.date === input.endsOn && input.endPart === 'morning')
|
|
3123
|
-
fraction = Math.min(fraction, 0.5);
|
|
3124
|
-
return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
|
|
3125
|
-
});
|
|
3126
|
-
const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
|
|
3127
|
-
const minutes = type.unit === 'hour' && input.hours
|
|
3128
|
-
? Math.round(input.hours * 60)
|
|
3129
|
-
: Math.round(workingDaysTotal * MINUTES_PER_DAY);
|
|
3130
|
-
if (minutes <= 0)
|
|
3131
|
-
blockers.push({
|
|
3132
|
-
code: 'empty',
|
|
3133
|
-
message: 'That range contains no working days.',
|
|
3134
|
-
});
|
|
3135
|
-
const year = yearOf(input.startsOn);
|
|
3136
|
-
const balances = await ledger.balances(tx, workspaceId, personId, year);
|
|
3137
|
-
const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
|
|
3138
|
-
const before = balance?.availableMinutes ?? 0;
|
|
3139
|
-
const after = before - minutes;
|
|
3140
|
-
if (after < 0 && !type.allowNegative)
|
|
3141
|
-
blockers.push({
|
|
3142
|
-
code: 'insufficient',
|
|
3143
|
-
message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
|
|
3144
|
-
});
|
|
3145
|
-
if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
|
|
3146
|
-
blockers.push({
|
|
3147
|
-
code: 'below_floor',
|
|
3148
|
-
message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
|
|
3149
|
-
});
|
|
3150
|
-
// Overlap is refused by a unique index as well; checking here turns a constraint violation into
|
|
3151
|
-
// a sentence naming the dates.
|
|
3152
|
-
const counted = days.filter((d) => d.counted).map((d) => d.date);
|
|
3153
|
-
if (counted.length) {
|
|
3154
|
-
const clash = await tx
|
|
3155
|
-
.select({ date: leaveRequestDays.date })
|
|
3156
|
-
.from(leaveRequestDays)
|
|
3157
|
-
.where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
|
|
3158
|
-
.limit(3);
|
|
3159
|
-
if (clash.length)
|
|
3160
|
-
blockers.push({
|
|
3161
|
-
code: 'overlap',
|
|
3162
|
-
message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
|
|
3163
|
-
});
|
|
3164
|
-
}
|
|
3165
|
-
if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
|
|
3166
|
-
blockers.push({
|
|
3167
|
-
code: 'document_required',
|
|
3168
|
-
message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
|
|
3169
|
-
});
|
|
3170
|
-
return {
|
|
3171
|
-
workingDays: workingDaysTotal,
|
|
3172
|
-
minutes,
|
|
3173
|
-
days,
|
|
3174
|
-
balanceBeforeMinutes: before,
|
|
3175
|
-
balanceAfterMinutes: after,
|
|
3176
|
-
blockers,
|
|
3177
|
-
};
|
|
3700
|
+
async function accountsOf(tx, workspaceId, personIds) {
|
|
3701
|
+
if (!personIds.length)
|
|
3702
|
+
return [];
|
|
3703
|
+
const rows = await tx
|
|
3704
|
+
.select({ userId: people.userId })
|
|
3705
|
+
.from(people)
|
|
3706
|
+
.where(and(eq(people.workspaceId, workspaceId), inArray(people.id, personIds)));
|
|
3707
|
+
return [...new Set(rows.map((r) => r.userId).filter((id) => !!id))];
|
|
3178
3708
|
}
|
|
3179
3709
|
/**
|
|
3180
|
-
*
|
|
3710
|
+
* Tell the people a newly raised request is waiting on.
|
|
3181
3711
|
*
|
|
3182
|
-
*
|
|
3183
|
-
*
|
|
3184
|
-
*
|
|
3712
|
+
* `hr.approval.requested` has always been *emitted*, and an event is not a notification: nothing
|
|
3713
|
+
* subscribes to it, so the first thing an approver ever heard about a request was the timeout
|
|
3714
|
+
* sweep reminding them about something they had never been told about in the first place.
|
|
3715
|
+
*
|
|
3716
|
+
* The route is the sweep's own, deliberately and to the letter: `core.notifications.create`, the
|
|
3717
|
+
* same `groupKey` so one request stays one card however often it is later reminded about, the
|
|
3718
|
+
* same `url`, and a catch per notification — by the time this runs the request is committed, so a
|
|
3719
|
+
* notification that fails must not become an error for the person who filed it. What they would
|
|
3720
|
+
* lose is a card; what a throw would cost them is the request.
|
|
3721
|
+
*
|
|
3722
|
+
* **After the transaction, never inside it.** Core writes on its own connection, so a
|
|
3723
|
+
* notification sent inside a transaction that then rolls back has already been delivered, and an
|
|
3724
|
+
* approver is holding a card for a request that does not exist.
|
|
3725
|
+
*
|
|
3726
|
+
* No sentence is composed here beyond the English fallback, for the reason the sweep gives: a
|
|
3727
|
+
* title built on the server is built before anyone knows who will read it, so it can only ever be
|
|
3728
|
+
* English on a Persian screen. `data` carries the subject type and the request's own
|
|
3729
|
+
* `summaryParams`, which is what a localised renderer needs to write the sentence itself.
|
|
3185
3730
|
*/
|
|
3186
|
-
async function
|
|
3187
|
-
const
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
minutes: sim.minutes,
|
|
3218
|
-
workingDays: String(sim.workingDays),
|
|
3219
|
-
decidedAt: new Date(),
|
|
3220
|
-
updatedAt: new Date(),
|
|
3221
|
-
})
|
|
3222
|
-
.where(eq(leaveRequests.id, request.id));
|
|
3731
|
+
async function notifyApprovers(notice) {
|
|
3732
|
+
for (const userId of notice.userIds)
|
|
3733
|
+
try {
|
|
3734
|
+
await kernel.call('core.notifications.create', {
|
|
3735
|
+
userId,
|
|
3736
|
+
workspaceId: notice.workspaceId,
|
|
3737
|
+
module: MODULE_ID,
|
|
3738
|
+
type: 'hr.approval.requested',
|
|
3739
|
+
title: 'Your approval is requested',
|
|
3740
|
+
body: notice.summary || null,
|
|
3741
|
+
object: null,
|
|
3742
|
+
url: '/hr/approvals',
|
|
3743
|
+
data: {
|
|
3744
|
+
subjectType: notice.subjectType,
|
|
3745
|
+
requestId: notice.requestId,
|
|
3746
|
+
params: notice.summaryParams ?? {},
|
|
3747
|
+
},
|
|
3748
|
+
groupKey: `hr.approval:${notice.requestId}`,
|
|
3749
|
+
// Whoever filed it, which is not always the person it is about — HR files leave for
|
|
3750
|
+
// somebody often enough that `requestedBy` exists as its own column.
|
|
3751
|
+
actorId: notice.actorId,
|
|
3752
|
+
}, kernel.system);
|
|
3753
|
+
}
|
|
3754
|
+
catch (err) {
|
|
3755
|
+
kernel.log.warn({
|
|
3756
|
+
module: 'hr',
|
|
3757
|
+
workspaceId: notice.workspaceId,
|
|
3758
|
+
requestId: notice.requestId,
|
|
3759
|
+
err: err.message,
|
|
3760
|
+
}, 'approval notification not delivered');
|
|
3761
|
+
}
|
|
3223
3762
|
}
|
|
3224
3763
|
async function clearDefaultChain(tx, workspaceId, subjectType) {
|
|
3225
3764
|
await tx
|
|
@@ -3275,24 +3814,6 @@ export function implement_(kernel) {
|
|
|
3275
3814
|
})),
|
|
3276
3815
|
};
|
|
3277
3816
|
}
|
|
3278
|
-
/**
|
|
3279
|
-
* Everything a punch needs about a person: their zone, and the schedule that shapes their day.
|
|
3280
|
-
*
|
|
3281
|
-
* The zone comes from the resolution ladder — their primary office unless they have an override —
|
|
3282
|
-
* so a punch made on a business trip still counts towards the month they are employed in.
|
|
3283
|
-
*
|
|
3284
|
-
* Everything here is resolved **as of today**, which is what a punch is about. It is therefore
|
|
3285
|
-
* not the place to answer a question about a past date: this used to hand out today's legal
|
|
3286
|
-
* entity as well, and three callers applied it to business dates months back — so a person who
|
|
3287
|
-
* transferred entity had a filed month recomputed against the one they are in now. `recomputeDay`
|
|
3288
|
-
* asks that question of the day it is rebuilding.
|
|
3289
|
-
*/
|
|
3290
|
-
async function personContext(tx, workspaceId, personId) {
|
|
3291
|
-
const today = todayIso();
|
|
3292
|
-
const resolution = await resolve.forPerson(tx, workspaceId, personId, today);
|
|
3293
|
-
const schedule = await attendance.scheduleFor(tx, workspaceId, personId, today);
|
|
3294
|
-
return { timezone: resolution.timezone, schedule, resolution };
|
|
3295
|
-
}
|
|
3296
3817
|
/**
|
|
3297
3818
|
* The same, plus the one decision every clock procedure has to agree about: which shift *this
|
|
3298
3819
|
* instant* belongs to, and what is already filed on it.
|
|
@@ -3389,43 +3910,6 @@ export function implement_(kernel) {
|
|
|
3389
3910
|
await changed(input.workspaceId, 'attendance_day', row.personId, 'updated');
|
|
3390
3911
|
return toPunch(row);
|
|
3391
3912
|
}
|
|
3392
|
-
/**
|
|
3393
|
-
* Apply an approved correction: write the proposed punches, void what they replace, rebuild.
|
|
3394
|
-
*
|
|
3395
|
-
* Nothing is edited. The original punch keeps its row and gains a pointer to what superseded it,
|
|
3396
|
-
* so a corrected timesheet and an edited one stay distinguishable — which is the entire reason
|
|
3397
|
-
* regularization exists rather than an update statement.
|
|
3398
|
-
*/
|
|
3399
|
-
async function applyRegularization(tx, workspaceId, regularizationId) {
|
|
3400
|
-
const [row] = await tx
|
|
3401
|
-
.select()
|
|
3402
|
-
.from(regularizations)
|
|
3403
|
-
.where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.id, regularizationId)))
|
|
3404
|
-
.limit(1);
|
|
3405
|
-
if (!row || row.status === 'approved')
|
|
3406
|
-
return;
|
|
3407
|
-
if (row.punchId)
|
|
3408
|
-
await attendance.voidPunch(tx, workspaceId, row.punchId, 'Regularized', null);
|
|
3409
|
-
const { timezone, schedule } = await personContext(tx, workspaceId, row.personId);
|
|
3410
|
-
for (const proposal of row.proposed)
|
|
3411
|
-
await tx.insert(punches).values({
|
|
3412
|
-
id: uuidv7(),
|
|
3413
|
-
workspaceId,
|
|
3414
|
-
personId: row.personId,
|
|
3415
|
-
direction: proposal.direction,
|
|
3416
|
-
at: new Date(proposal.at),
|
|
3417
|
-
businessDate: row.businessDate,
|
|
3418
|
-
timezone,
|
|
3419
|
-
method: 'manual',
|
|
3420
|
-
trust: 'trusted',
|
|
3421
|
-
note: `Regularization ${row.id}`,
|
|
3422
|
-
});
|
|
3423
|
-
await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
|
|
3424
|
-
await tx
|
|
3425
|
-
.update(regularizations)
|
|
3426
|
-
.set({ status: 'approved', appliedAt: new Date() })
|
|
3427
|
-
.where(eq(regularizations.id, row.id));
|
|
3428
|
-
}
|
|
3429
3913
|
async function loadPolicy(tx, workspaceId, policyId) {
|
|
3430
3914
|
const [row] = await tx
|
|
3431
3915
|
.select()
|