@kernhq/module-hr 0.15.0 → 0.16.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.
@@ -84,6 +84,332 @@ function paginate(rows, limit, cursorOf) {
84
84
  const [key, id] = cursorOf(items[items.length - 1]);
85
85
  return { items, nextCursor: encodeCursor(key, id) };
86
86
  }
87
+ async function loadCalendar(tx, workspaceId, calendarId) {
88
+ const [row] = await tx
89
+ .select()
90
+ .from(calendars)
91
+ .where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, calendarId)))
92
+ .limit(1);
93
+ if (!row)
94
+ throw KernError.notFound('Calendar');
95
+ return row;
96
+ }
97
+ /** The chain nearest-first: this calendar, then whatever it extends. */
98
+ async function calendarChain(tx, workspaceId, calendarId) {
99
+ const chain = [];
100
+ let cursor = calendarId;
101
+ for (let depth = 0; depth < 4 && cursor; depth++) {
102
+ const row = await loadCalendar(tx, workspaceId, cursor);
103
+ chain.push(row);
104
+ cursor = row.extendsId;
105
+ }
106
+ return chain;
107
+ }
108
+ /**
109
+ * The composed calendar over a range: this calendar's days over the ones it extends.
110
+ *
111
+ * Nearest wins per date and kind, and a day that shadows one from a calendar further down is
112
+ * marked `overrides` so the editor can show what it is replacing — which is what makes "we work
113
+ * through this national holiday" legible rather than looking like a missing holiday.
114
+ *
115
+ * At module scope, because it never needed the router's closure — only a `tx`. That is what lets
116
+ * `hrSubjects` below reach it, so the leave calculation a deadline runs reads the same calendar as
117
+ * the one a person runs.
118
+ */
119
+ async function composedDays(tx, workspaceId, calendarId, from, to) {
120
+ const chain = await calendarChain(tx, workspaceId, calendarId);
121
+ const rows = await tx
122
+ .select()
123
+ .from(calendarDays)
124
+ .where(and(eq(calendarDays.workspaceId, workspaceId), inArray(calendarDays.calendarId, chain.map((c) => c.id)), gte(calendarDays.date, from), lte(calendarDays.date, to)));
125
+ const nameById = new Map(chain.map((c) => [c.id, c.name]));
126
+ const seen = new Map();
127
+ const datesFromNearest = new Set();
128
+ for (const cal of chain) {
129
+ for (const row of rows.filter((r) => r.calendarId === cal.id)) {
130
+ const key = `${row.date}:${row.kind}`;
131
+ if (seen.has(key))
132
+ continue;
133
+ const overrides = cal.id !== calendarId ? false : datesFromNearest.has(row.date);
134
+ seen.set(key, toResolvedDay(row, cal.id, nameById.get(cal.id) ?? '', overrides));
135
+ if (cal.id === calendarId)
136
+ datesFromNearest.add(row.date);
137
+ }
138
+ }
139
+ // Second pass: a nearest-calendar day covering a date the base also has *is* an override, and
140
+ // the first pass cannot know that until the base has been read.
141
+ const baseDates = new Set(rows.filter((r) => r.calendarId !== calendarId).map((r) => r.date));
142
+ return [...seen.values()]
143
+ .map((d) => ({ ...d, overrides: d.fromCalendarId === calendarId && baseDates.has(d.date) }))
144
+ .sort((a, b) => a.date.localeCompare(b.date));
145
+ }
146
+ /**
147
+ * What a *decided* request does to its subject, and the calculation both halves of that stand on.
148
+ *
149
+ * These lived inside `implement_`'s closure, and that is the whole reason `ApprovalService` had
150
+ * appliers only when a person was on the other end of the call: a job cannot reach into a router's
151
+ * closure, so `sweepTimeouts` would advance an intermediate step and then refuse the step that
152
+ * *completes* a request — logging that it had reminded instead. A deadline an administrator set,
153
+ * believed, and told their staff about did nothing on the one step that mattered.
154
+ *
155
+ * So it is a factory both callers can reach: `implement_` below, and `hrJobs` in `jobs.ts`. It is a
156
+ * factory rather than a class because the closure is the point — `applyApproval` needs `simulate`,
157
+ * `simulate` needs the ledger and the composed calendar, and threading those through method
158
+ * arguments would buy nothing.
159
+ *
160
+ * The alternative was a second `simulate` in the job, and it is worth naming why not: two copies of
161
+ * a leave calculation drift, both of them type-check while they drift, and the first sign of it is
162
+ * an employee whose balance disagrees with the days they were granted.
163
+ *
164
+ * Services are passed in rather than constructed here so a caller keeps one instance of each — two
165
+ * `AttendanceService`s is not a bug today and is one cache away from being one.
166
+ */
167
+ export function hrSubjects(deps) {
168
+ const { resolve, ledger, attendance } = deps;
169
+ async function loadRequest(tx, workspaceId, requestId) {
170
+ const [row] = await tx
171
+ .select()
172
+ .from(leaveRequests)
173
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.id, requestId)))
174
+ .limit(1);
175
+ if (!row)
176
+ throw KernError.notFound('Leave request');
177
+ return row;
178
+ }
179
+ /**
180
+ * What a request would cost, and every reason it would be refused.
181
+ *
182
+ * Used by `simulate` *and* by `create`, deliberately: a preview that runs different code from the
183
+ * submission is a preview that eventually lies. The blockers are returned rather than thrown here
184
+ * so the screen can show all of them at once instead of one per round trip.
185
+ */
186
+ async function simulate(tx, workspaceId, personId, input) {
187
+ const blockers = [];
188
+ if (input.endsOn < input.startsOn)
189
+ blockers.push({ code: 'range', message: 'The end date is before the start date.' });
190
+ const [type] = await tx
191
+ .select()
192
+ .from(leaveTypes)
193
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), eq(leaveTypes.id, input.leaveTypeId)))
194
+ .limit(1);
195
+ if (!type)
196
+ throw KernError.notFound('Leave type');
197
+ if (type.archivedAt)
198
+ blockers.push({ code: 'archived', message: `${type.name} is no longer available.` });
199
+ const resolution = await resolve.forPerson(tx, workspaceId, personId, input.startsOn);
200
+ const calendarDaysInRange = resolution.calendarId
201
+ ? await composedDays(tx, workspaceId, resolution.calendarId, input.startsOn, input.endsOn)
202
+ : [];
203
+ const results = workingDays(input.startsOn, input.endsOn, resolution.workingWeek, type.countsWorkingDaysOnly
204
+ ? calendarDaysInRange.map((d) => ({
205
+ date: d.date,
206
+ name: d.name,
207
+ workingFraction: d.workingFraction,
208
+ }))
209
+ : []);
210
+ // Half-days trim the ends. Applied after the calendar, so asking for a half day on a public
211
+ // holiday still costs nothing rather than costing half of nothing.
212
+ const days = results.map((r) => {
213
+ let fraction = r.fraction;
214
+ if (r.date === input.startsOn && input.startPart === 'afternoon')
215
+ fraction = Math.min(fraction, 0.5);
216
+ if (r.date === input.endsOn && input.endPart === 'morning')
217
+ fraction = Math.min(fraction, 0.5);
218
+ return { date: r.date, fraction, counted: fraction > 0, reason: r.reason };
219
+ });
220
+ const workingDaysTotal = Math.round(days.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100;
221
+ const minutes = type.unit === 'hour' && input.hours
222
+ ? Math.round(input.hours * 60)
223
+ : Math.round(workingDaysTotal * MINUTES_PER_DAY);
224
+ if (minutes <= 0)
225
+ blockers.push({
226
+ code: 'empty',
227
+ message: 'That range contains no working days.',
228
+ });
229
+ const year = yearOf(input.startsOn);
230
+ const balances = await ledger.balances(tx, workspaceId, personId, year);
231
+ const balance = balances.find((b) => b.leaveTypeId === input.leaveTypeId);
232
+ const before = balance?.availableMinutes ?? 0;
233
+ const after = before - minutes;
234
+ if (after < 0 && !type.allowNegative)
235
+ blockers.push({
236
+ code: 'insufficient',
237
+ message: `Not enough ${type.name}: this would leave ${Math.round((after / MINUTES_PER_DAY) * 100) / 100} days.`,
238
+ });
239
+ if (after < 0 && type.allowNegative && Math.abs(after) > type.maxNegativeMinutes)
240
+ blockers.push({
241
+ code: 'below_floor',
242
+ message: `${type.name} cannot go further than ${Math.round(type.maxNegativeMinutes / MINUTES_PER_DAY)} days negative.`,
243
+ });
244
+ // Overlap is refused by a unique index as well; checking here turns a constraint violation into
245
+ // a sentence naming the dates.
246
+ const counted = days.filter((d) => d.counted).map((d) => d.date);
247
+ if (counted.length) {
248
+ const clash = await tx
249
+ .select({ date: leaveRequestDays.date })
250
+ .from(leaveRequestDays)
251
+ .where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.counted, true), inArray(leaveRequestDays.status, ['pending', 'approved']), inArray(leaveRequestDays.date, counted)))
252
+ .limit(3);
253
+ if (clash.length)
254
+ blockers.push({
255
+ code: 'overlap',
256
+ message: `You already have leave booked on ${clash.map((c) => c.date).join(', ')}.`,
257
+ });
258
+ }
259
+ if (type.requiresDocumentAfterDays !== null && workingDaysTotal > type.requiresDocumentAfterDays)
260
+ blockers.push({
261
+ code: 'document_required',
262
+ message: `${type.name} longer than ${type.requiresDocumentAfterDays} days needs a document.`,
263
+ });
264
+ return {
265
+ workingDays: workingDaysTotal,
266
+ minutes,
267
+ days,
268
+ balanceBeforeMinutes: before,
269
+ balanceAfterMinutes: after,
270
+ blockers,
271
+ };
272
+ }
273
+ /**
274
+ * Turn an approved request into a ledger consumption.
275
+ *
276
+ * The working days are **recomputed here** rather than trusted from submission time: a holiday
277
+ * can be added to the calendar between asking and approving, and the number that costs somebody
278
+ * balance should be the one that was true when it was granted.
279
+ */
280
+ async function applyApproval(tx, workspaceId, leaveRequestId, actorId) {
281
+ const request = await loadRequest(tx, workspaceId, leaveRequestId);
282
+ if (request.status === 'approved')
283
+ return;
284
+ const sim = await simulate(tx, workspaceId, request.personId, {
285
+ leaveTypeId: request.leaveTypeId,
286
+ startsOn: request.startsOn,
287
+ endsOn: request.endsOn,
288
+ startPart: request.startPart,
289
+ endPart: request.endPart,
290
+ hours: request.hours === null ? null : Number.parseFloat(request.hours),
291
+ });
292
+ await ledger.append(tx, workspaceId, {
293
+ personId: request.personId,
294
+ leaveTypeId: request.leaveTypeId,
295
+ kind: 'consumption',
296
+ amountMinutes: -sim.minutes,
297
+ effectiveOn: request.startsOn,
298
+ periodYear: yearOf(request.startsOn),
299
+ requestId: request.id,
300
+ reason: null,
301
+ createdBy: actorId,
302
+ });
303
+ await tx
304
+ .update(leaveRequestDays)
305
+ .set({ status: 'approved' })
306
+ .where(eq(leaveRequestDays.requestId, request.id));
307
+ await tx
308
+ .update(leaveRequests)
309
+ .set({
310
+ status: 'approved',
311
+ minutes: sim.minutes,
312
+ workingDays: String(sim.workingDays),
313
+ decidedAt: new Date(),
314
+ updatedAt: new Date(),
315
+ })
316
+ .where(eq(leaveRequests.id, request.id));
317
+ }
318
+ /**
319
+ * Everything a punch needs about a person: their zone, and the schedule that shapes their day.
320
+ *
321
+ * The zone comes from the resolution ladder — their primary office unless they have an override —
322
+ * so a punch made on a business trip still counts towards the month they are employed in.
323
+ *
324
+ * Everything here is resolved **as of today**, which is what a punch is about. It is therefore
325
+ * not the place to answer a question about a past date: this used to hand out today's legal
326
+ * entity as well, and three callers applied it to business dates months back — so a person who
327
+ * transferred entity had a filed month recomputed against the one they are in now. `recomputeDay`
328
+ * asks that question of the day it is rebuilding.
329
+ */
330
+ async function personContext(tx, workspaceId, personId) {
331
+ const today = todayIso();
332
+ const resolution = await resolve.forPerson(tx, workspaceId, personId, today);
333
+ const schedule = await attendance.scheduleFor(tx, workspaceId, personId, today);
334
+ return { timezone: resolution.timezone, schedule, resolution };
335
+ }
336
+ /**
337
+ * Apply an approved correction: write the proposed punches, void what they replace, rebuild.
338
+ *
339
+ * Nothing is edited. The original punch keeps its row and gains a pointer to what superseded it,
340
+ * so a corrected timesheet and an edited one stay distinguishable — which is the entire reason
341
+ * regularization exists rather than an update statement.
342
+ */
343
+ async function applyRegularization(tx, workspaceId, regularizationId) {
344
+ const [row] = await tx
345
+ .select()
346
+ .from(regularizations)
347
+ .where(and(eq(regularizations.workspaceId, workspaceId), eq(regularizations.id, regularizationId)))
348
+ .limit(1);
349
+ if (!row || row.status === 'approved')
350
+ return;
351
+ if (row.punchId)
352
+ await attendance.voidPunch(tx, workspaceId, row.punchId, 'Regularized', null);
353
+ const { timezone, schedule } = await personContext(tx, workspaceId, row.personId);
354
+ for (const proposal of row.proposed)
355
+ await tx.insert(punches).values({
356
+ id: uuidv7(),
357
+ workspaceId,
358
+ personId: row.personId,
359
+ direction: proposal.direction,
360
+ at: new Date(proposal.at),
361
+ businessDate: row.businessDate,
362
+ timezone,
363
+ method: 'manual',
364
+ trust: 'trusted',
365
+ note: `Regularization ${row.id}`,
366
+ });
367
+ await attendance.recomputeDay(tx, workspaceId, row.personId, row.businessDate, timezone, schedule);
368
+ await tx
369
+ .update(regularizations)
370
+ .set({ status: 'approved', appliedAt: new Date() })
371
+ .where(eq(regularizations.id, row.id));
372
+ }
373
+ /** A rejected request costs no balance and writes no punches; it just stops being live. */
374
+ async function applyLeaveDecision(tx, workspaceId, leaveRequestId, status, actorId) {
375
+ if (status === 'approved')
376
+ return applyApproval(tx, workspaceId, leaveRequestId, actorId);
377
+ await tx
378
+ .update(leaveRequests)
379
+ .set({ status: 'rejected', decidedAt: new Date(), updatedAt: new Date() })
380
+ .where(eq(leaveRequests.id, leaveRequestId));
381
+ }
382
+ /** The same, for a correction. */
383
+ async function applyRegularizationDecision(tx, workspaceId, regularizationId, status) {
384
+ if (status === 'approved')
385
+ return applyRegularization(tx, workspaceId, regularizationId);
386
+ await tx
387
+ .update(regularizations)
388
+ .set({ status: 'rejected' })
389
+ .where(eq(regularizations.id, regularizationId));
390
+ }
391
+ return {
392
+ loadRequest,
393
+ simulate,
394
+ applyApproval,
395
+ applyRegularization,
396
+ personContext,
397
+ /**
398
+ * The same two functions in the shape `ApprovalService` calls them in, keyed by `subjectType` —
399
+ * the only thing the engine knows about a subject.
400
+ *
401
+ * Parameterised by the actor because that is the one thing the two callers genuinely disagree
402
+ * about: a person approving leave is written onto the ledger entry as `created_by`, and a
403
+ * deadline running out is written as nobody. Passing the approver's id for a timeout would put
404
+ * a name against a decision that person did not make, which is exactly what
405
+ * `TIMEOUT_APPROVER_ID` exists to avoid one table over.
406
+ */
407
+ appliersFor: (actorId) => ({
408
+ leave: (tx, workspaceId, request, status) => applyLeaveDecision(tx, workspaceId, request.subjectId, status, actorId),
409
+ regularization: (tx, workspaceId, request, status) => applyRegularizationDecision(tx, workspaceId, request.subjectId, status),
410
+ }),
411
+ };
412
+ }
87
413
  /**
88
414
  * The router.
89
415
  *
@@ -103,9 +429,16 @@ export function implement_(kernel) {
103
429
  const svc = new PeopleService(kernel);
104
430
  const access = new HrAccessService(kernel);
105
431
  const ledger = new LedgerService();
106
- const approvals = new ApprovalService(kernel);
107
432
  const policySvc = new PolicyService(resolve);
108
433
  const attendance = new AttendanceService(resolve, policySvc);
434
+ const subjects = hrSubjects({ resolve, ledger, attendance });
435
+ const { applyApproval, applyRegularization, loadRequest, personContext, simulate } = subjects;
436
+ /**
437
+ * The engine gets the appliers here as well as in `jobs.ts`, so the two constructions read the
438
+ * same and stay the same. A sweep started from a request is still nobody's decision, which is why
439
+ * this one is built for no actor — the per-request actor arrives at `decide` below.
440
+ */
441
+ const approvals = new ApprovalService(kernel, subjects.appliersFor(null));
109
442
  const db = kernel.database;
110
443
  const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
111
444
  const changed = (workspaceId, entity, id, op) => kernel.realtime.change(workspaceId, { module: MODULE_ID, entity, id, op });
@@ -2036,10 +2369,17 @@ export function implement_(kernel) {
2036
2369
  // Same reason as leave: the approvers are told after this commits, never inside it.
2037
2370
  return {
2038
2371
  row: fresh,
2039
- approval: { requestId: raised.request.id, approverIds: raised.firstStepApprovers },
2372
+ approval: {
2373
+ requestId: raised.request.id,
2374
+ approverIds: raised.firstStepApprovers,
2375
+ userIds: await accountsOf(tx, input.workspaceId, raised.firstStepApprovers),
2376
+ summary: raised.request.summary,
2377
+ summaryParams: raised.request.summaryParams,
2378
+ actorId: raised.request.requestedBy,
2379
+ },
2040
2380
  };
2041
2381
  });
2042
- if (filed.approval.approverIds.length)
2382
+ if (filed.approval.approverIds.length) {
2043
2383
  await kernel.emit(hrEvents.approvalRequested, {
2044
2384
  requestId: filed.approval.requestId,
2045
2385
  workspaceId: input.workspaceId,
@@ -2047,6 +2387,16 @@ export function implement_(kernel) {
2047
2387
  subjectId: filed.row.id,
2048
2388
  approverIds: filed.approval.approverIds,
2049
2389
  }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
2390
+ await notifyApprovers({
2391
+ workspaceId: input.workspaceId,
2392
+ requestId: filed.approval.requestId,
2393
+ subjectType: 'regularization',
2394
+ summary: filed.approval.summary,
2395
+ summaryParams: filed.approval.summaryParams,
2396
+ userIds: filed.approval.userIds,
2397
+ actorId: filed.approval.actorId,
2398
+ });
2399
+ }
2050
2400
  await changed(input.workspaceId, 'regularization', filed.row.id, 'created');
2051
2401
  return toRegularization(filed.row);
2052
2402
  }),
@@ -2357,7 +2707,17 @@ export function implement_(kernel) {
2357
2707
  request: fresh,
2358
2708
  personId,
2359
2709
  replay: false,
2360
- approval: { requestId: raised.request.id, approverIds: raised.firstStepApprovers },
2710
+ approval: {
2711
+ requestId: raised.request.id,
2712
+ approverIds: raised.firstStepApprovers,
2713
+ // Resolved here because it needs `tx`, delivered outside because a notification
2714
+ // cannot be rolled back. Person ids are HR's identity and accounts are core's, so
2715
+ // the translation happens once, on the way out.
2716
+ userIds: await accountsOf(tx, input.workspaceId, raised.firstStepApprovers),
2717
+ summary: raised.request.summary,
2718
+ summaryParams: raised.request.summaryParams,
2719
+ actorId: raised.request.requestedBy,
2720
+ },
2361
2721
  };
2362
2722
  });
2363
2723
  const result = await filing.catch(async (err) => {
@@ -2389,7 +2749,7 @@ export function implement_(kernel) {
2389
2749
  // the people the *first* step is on. Nothing for a chain that resolved to nobody — that
2390
2750
  // was approved on the way in and is not waiting on anyone. The ids are person ids, the
2391
2751
  // same identity the rest of `hr.*` carries.
2392
- if (result.approval.approverIds.length)
2752
+ if (result.approval.approverIds.length) {
2393
2753
  await kernel.emit(hrEvents.approvalRequested, {
2394
2754
  requestId: result.approval.requestId,
2395
2755
  workspaceId: input.workspaceId,
@@ -2397,6 +2757,18 @@ export function implement_(kernel) {
2397
2757
  subjectId: result.request.id,
2398
2758
  approverIds: result.approval.approverIds,
2399
2759
  }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
2760
+ // And then the approvers themselves. The event is for other modules; this is for the
2761
+ // people whose signature the request is now waiting on.
2762
+ await notifyApprovers({
2763
+ workspaceId: input.workspaceId,
2764
+ requestId: result.approval.requestId,
2765
+ subjectType: 'leave',
2766
+ summary: result.approval.summary,
2767
+ summaryParams: result.approval.summaryParams,
2768
+ userIds: result.approval.userIds,
2769
+ actorId: result.approval.actorId,
2770
+ });
2771
+ }
2400
2772
  await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
2401
2773
  return toLeaveRequest(result.request);
2402
2774
  }),
@@ -2547,24 +2919,15 @@ export function implement_(kernel) {
2547
2919
  // The approval engine knows nothing about leave. Applying the decision to the subject is
2548
2920
  // the caller's job, which is what keeps the engine reusable for regularization and
2549
2921
  // overtime later.
2922
+ //
2923
+ // Through the same appliers the timeout sweep is given, rather than a branch of its own:
2924
+ // this used to be a `switch` on `subjectType` here and nothing at all in the job, which is
2925
+ // how a deadline could approve a request and leave its leave unbooked. One table of
2926
+ // subject types, and adding overtime means adding a line to it and nothing here.
2550
2927
  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));
2928
+ if (result.status !== 'pending') {
2929
+ const apply = subjects.appliersFor(context.principal.userId ?? null)[request.subjectType];
2930
+ await apply?.(tx, input.workspaceId, request, result.status);
2568
2931
  }
2569
2932
  const [fresh] = await tx
2570
2933
  .select()
@@ -2819,16 +3182,6 @@ export function implement_(kernel) {
2819
3182
  throw KernError.notFound('Office');
2820
3183
  return row;
2821
3184
  }
2822
- async function loadCalendar(tx, workspaceId, calendarId) {
2823
- const [row] = await tx
2824
- .select()
2825
- .from(calendars)
2826
- .where(and(eq(calendars.workspaceId, workspaceId), eq(calendars.id, calendarId)))
2827
- .limit(1);
2828
- if (!row)
2829
- throw KernError.notFound('Calendar');
2830
- return row;
2831
- }
2832
3185
  /** The workspace's calendar for a country pack, created on first use so offices can share one. */
2833
3186
  async function packCalendar(tx, workspaceId, country) {
2834
3187
  const [existing] = await tx
@@ -2892,51 +3245,6 @@ export function implement_(kernel) {
2892
3245
  }
2893
3246
  throw KernError.badRequest('Calendars may only be built on three levels.');
2894
3247
  }
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
3248
  /**
2941
3249
  * What applying a pack would do — and, just as importantly, what it would leave alone.
2942
3250
  *
@@ -3072,154 +3380,75 @@ export function implement_(kernel) {
3072
3380
  .limit(1);
3073
3381
  return row;
3074
3382
  }
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
3383
  /**
3086
- * What a request would cost, and every reason it would be refused.
3384
+ * The Kern accounts behind a set of people.
3087
3385
  *
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.
3386
+ * An employee need not have an account, and one removed from the workspace has had the link
3387
+ * cleared on purpose by the `core.member.removed` subscription. Both are "nothing to deliver",
3388
+ * not an error the same rule `sweepTimeouts` applies to the people it has to reach.
3091
3389
  */
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
- };
3390
+ async function accountsOf(tx, workspaceId, personIds) {
3391
+ if (!personIds.length)
3392
+ return [];
3393
+ const rows = await tx
3394
+ .select({ userId: people.userId })
3395
+ .from(people)
3396
+ .where(and(eq(people.workspaceId, workspaceId), inArray(people.id, personIds)));
3397
+ return [...new Set(rows.map((r) => r.userId).filter((id) => !!id))];
3178
3398
  }
3179
3399
  /**
3180
- * Turn an approved request into a ledger consumption.
3400
+ * Tell the people a newly raised request is waiting on.
3181
3401
  *
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.
3402
+ * `hr.approval.requested` has always been *emitted*, and an event is not a notification: nothing
3403
+ * subscribes to it, so the first thing an approver ever heard about a request was the timeout
3404
+ * sweep reminding them about something they had never been told about in the first place.
3405
+ *
3406
+ * The route is the sweep's own, deliberately and to the letter: `core.notifications.create`, the
3407
+ * same `groupKey` so one request stays one card however often it is later reminded about, the
3408
+ * same `url`, and a catch per notification — by the time this runs the request is committed, so a
3409
+ * notification that fails must not become an error for the person who filed it. What they would
3410
+ * lose is a card; what a throw would cost them is the request.
3411
+ *
3412
+ * **After the transaction, never inside it.** Core writes on its own connection, so a
3413
+ * notification sent inside a transaction that then rolls back has already been delivered, and an
3414
+ * approver is holding a card for a request that does not exist.
3415
+ *
3416
+ * No sentence is composed here beyond the English fallback, for the reason the sweep gives: a
3417
+ * title built on the server is built before anyone knows who will read it, so it can only ever be
3418
+ * English on a Persian screen. `data` carries the subject type and the request's own
3419
+ * `summaryParams`, which is what a localised renderer needs to write the sentence itself.
3185
3420
  */
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));
3421
+ async function notifyApprovers(notice) {
3422
+ for (const userId of notice.userIds)
3423
+ try {
3424
+ await kernel.call('core.notifications.create', {
3425
+ userId,
3426
+ workspaceId: notice.workspaceId,
3427
+ module: MODULE_ID,
3428
+ type: 'hr.approval.requested',
3429
+ title: 'Your approval is requested',
3430
+ body: notice.summary || null,
3431
+ object: null,
3432
+ url: '/hr/approvals',
3433
+ data: {
3434
+ subjectType: notice.subjectType,
3435
+ requestId: notice.requestId,
3436
+ params: notice.summaryParams ?? {},
3437
+ },
3438
+ groupKey: `hr.approval:${notice.requestId}`,
3439
+ // Whoever filed it, which is not always the person it is about — HR files leave for
3440
+ // somebody often enough that `requestedBy` exists as its own column.
3441
+ actorId: notice.actorId,
3442
+ }, kernel.system);
3443
+ }
3444
+ catch (err) {
3445
+ kernel.log.warn({
3446
+ module: 'hr',
3447
+ workspaceId: notice.workspaceId,
3448
+ requestId: notice.requestId,
3449
+ err: err.message,
3450
+ }, 'approval notification not delivered');
3451
+ }
3223
3452
  }
3224
3453
  async function clearDefaultChain(tx, workspaceId, subjectType) {
3225
3454
  await tx
@@ -3275,24 +3504,6 @@ export function implement_(kernel) {
3275
3504
  })),
3276
3505
  };
3277
3506
  }
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
3507
  /**
3297
3508
  * The same, plus the one decision every clock procedure has to agree about: which shift *this
3298
3509
  * instant* belongs to, and what is already filed on it.
@@ -3389,43 +3600,6 @@ export function implement_(kernel) {
3389
3600
  await changed(input.workspaceId, 'attendance_day', row.personId, 'updated');
3390
3601
  return toPunch(row);
3391
3602
  }
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
3603
  async function loadPolicy(tx, workspaceId, policyId) {
3430
3604
  const [row] = await tx
3431
3605
  .select()