@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.
Files changed (57) hide show
  1. package/dist/contract/index.d.ts +1 -0
  2. package/dist/contract/index.d.ts.map +1 -1
  3. package/dist/contract/index.js +1 -0
  4. package/dist/contract/index.js.map +1 -1
  5. package/dist/contract/models.d.ts +1 -0
  6. package/dist/contract/models.d.ts.map +1 -1
  7. package/dist/contract/models.js +15 -0
  8. package/dist/contract/models.js.map +1 -1
  9. package/dist/contract/permissions.d.ts +8 -0
  10. package/dist/contract/permissions.d.ts.map +1 -1
  11. package/dist/contract/permissions.js +20 -0
  12. package/dist/contract/permissions.js.map +1 -1
  13. package/dist/contract/privacy.d.ts +860 -0
  14. package/dist/contract/privacy.d.ts.map +1 -0
  15. package/dist/contract/privacy.js +412 -0
  16. package/dist/contract/privacy.js.map +1 -0
  17. package/dist/contract/router.d.ts +815 -0
  18. package/dist/contract/router.d.ts.map +1 -1
  19. package/dist/contract/router.js +105 -0
  20. package/dist/contract/router.js.map +1 -1
  21. package/dist/server/index.d.ts.map +1 -1
  22. package/dist/server/index.js +51 -0
  23. package/dist/server/index.js.map +1 -1
  24. package/dist/server/jobs.d.ts.map +1 -1
  25. package/dist/server/jobs.js +44 -7
  26. package/dist/server/jobs.js.map +1 -1
  27. package/dist/server/router.d.ts +994 -1
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +766 -282
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +397 -1
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +109 -0
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/audit.d.ts +249 -0
  36. package/dist/server/services/audit.d.ts.map +1 -0
  37. package/dist/server/services/audit.js +230 -0
  38. package/dist/server/services/audit.js.map +1 -0
  39. package/dist/server/services/people.d.ts +53 -1
  40. package/dist/server/services/people.d.ts.map +1 -1
  41. package/dist/server/services/people.js +74 -1
  42. package/dist/server/services/people.js.map +1 -1
  43. package/dist/server/services/privacy.d.ts +514 -0
  44. package/dist/server/services/privacy.d.ts.map +1 -0
  45. package/dist/server/services/privacy.js +972 -0
  46. package/dist/server/services/privacy.js.map +1 -0
  47. package/migrations/0011_privacy.sql +77 -0
  48. package/migrations/meta/0011_snapshot.json +4450 -0
  49. package/migrations/meta/_journal.json +7 -0
  50. package/package.json +1 -1
  51. package/src/client/messages.ts +17 -0
  52. package/src/client/widgets/ApprovalsWidget.svelte +119 -8
  53. package/src/contract/index.ts +1 -0
  54. package/src/contract/models.ts +15 -0
  55. package/src/contract/permissions.ts +22 -0
  56. package/src/contract/privacy.ts +452 -0
  57. package/src/contract/router.ts +121 -0
@@ -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
- return forViewer(PeopleService.toPerson(await svc.load(tx, input.workspaceId, input.personId)), visible);
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: { requestId: raised.request.id, approverIds: raised.firstStepApprovers },
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: { requestId: raised.request.id, approverIds: raised.firstStepApprovers },
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 (request.subjectType === 'leave') {
2552
- if (result.status === 'approved')
2553
- await applyApproval(tx, input.workspaceId, request.subjectId, context.principal.userId ?? null);
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
- // ------------------------------------------------------------------ helpers
2810
- // Closures over `kernel` and `db`, kept at the bottom so the router above reads as a list of
2811
- // procedures rather than a list of procedures interrupted by plumbing.
2812
- async function loadOffice(tx, input) {
2813
- const [row] = await tx
2814
- .select()
2815
- .from(offices)
2816
- .where(and(eq(offices.workspaceId, input.workspaceId), eq(offices.id, input.officeId)))
2817
- .limit(1);
2818
- if (!row)
2819
- throw KernError.notFound('Office');
2820
- return row;
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 loadCalendar(tx, workspaceId, calendarId) {
3485
+ async function loadOffice(tx, input) {
2823
3486
  const [row] = await tx
2824
3487
  .select()
2825
- .from(calendars)
2826
- .where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, calendarId)))
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('Calendar');
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
- * What a request would cost, and every reason it would be refused.
3694
+ * The Kern accounts behind a set of people.
3087
3695
  *
3088
- * Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
3089
- * submission is a preview that eventually lies. The blockers are returned rather than thrown here
3090
- * so the screen can show all of them at once instead of one per round trip.
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 simulate(tx, workspaceId, personId, input) {
3093
- const blockers = [];
3094
- if (input.endsOn < input.startsOn)
3095
- blockers.push({ code: 'range', message: 'The end date is before the start date.' });
3096
- const [type] = await tx
3097
- .select()
3098
- .from(leaveTypes)
3099
- .where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
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
- * Turn an approved request into a ledger consumption.
3710
+ * Tell the people a newly raised request is waiting on.
3181
3711
  *
3182
- * The working days are **recomputed here** rather than trusted from submission time: a holiday
3183
- * can be added to the calendar between asking and approving, and the number that costs somebody
3184
- * balance should be the one that was true when it was granted.
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 applyApproval(tx, workspaceId, leaveRequestId, actorId) {
3187
- const request = await loadRequest(tx, workspaceId, leaveRequestId);
3188
- if (request.status === 'approved')
3189
- return;
3190
- const sim = await simulate(tx, workspaceId, request.personId, {
3191
- leaveTypeId: request.leaveTypeId,
3192
- startsOn: request.startsOn,
3193
- endsOn: request.endsOn,
3194
- startPart: request.startPart,
3195
- endPart: request.endPart,
3196
- hours: request.hours === null ? null : Number.parseFloat(request.hours),
3197
- });
3198
- await ledger.append(tx, workspaceId, {
3199
- personId: request.personId,
3200
- leaveTypeId: request.leaveTypeId,
3201
- kind: 'consumption',
3202
- amountMinutes: -sim.minutes,
3203
- effectiveOn: request.startsOn,
3204
- periodYear: yearOf(request.startsOn),
3205
- requestId: request.id,
3206
- reason: null,
3207
- createdBy: actorId,
3208
- });
3209
- await tx
3210
- .update(leaveRequestDays)
3211
- .set({ status: 'approved' })
3212
- .where(eq(leaveRequestDays.requestId, request.id));
3213
- await tx
3214
- .update(leaveRequests)
3215
- .set({
3216
- status: 'approved',
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()