@kernhq/module-hr 0.13.1 → 0.14.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 (61) hide show
  1. package/dist/contract/attendance.d.ts +35 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +25 -1
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts.map +1 -1
  6. package/dist/contract/capabilities.js +10 -0
  7. package/dist/contract/capabilities.js.map +1 -1
  8. package/dist/contract/events.d.ts +17 -16
  9. package/dist/contract/events.d.ts.map +1 -1
  10. package/dist/contract/events.js +23 -16
  11. package/dist/contract/events.js.map +1 -1
  12. package/dist/contract/models.d.ts +1 -0
  13. package/dist/contract/models.d.ts.map +1 -1
  14. package/dist/contract/models.js +11 -0
  15. package/dist/contract/models.js.map +1 -1
  16. package/dist/contract/router.d.ts +12 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +11 -3
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/contract/settings.d.ts +0 -1
  21. package/dist/contract/settings.d.ts.map +1 -1
  22. package/dist/contract/settings.js +11 -5
  23. package/dist/contract/settings.js.map +1 -1
  24. package/dist/server/index.d.ts +0 -1
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +30 -0
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/jobs.d.ts.map +1 -1
  29. package/dist/server/jobs.js +13 -5
  30. package/dist/server/jobs.js.map +1 -1
  31. package/dist/server/router.d.ts +12 -0
  32. package/dist/server/router.d.ts.map +1 -1
  33. package/dist/server/router.js +374 -76
  34. package/dist/server/router.js.map +1 -1
  35. package/dist/server/services/access.d.ts +176 -0
  36. package/dist/server/services/access.d.ts.map +1 -0
  37. package/dist/server/services/access.js +260 -0
  38. package/dist/server/services/access.js.map +1 -0
  39. package/dist/server/services/people.d.ts +1 -0
  40. package/dist/server/services/people.d.ts.map +1 -1
  41. package/dist/server/services/people.js +3 -0
  42. package/dist/server/services/people.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/client/components/DayDetail.svelte +32 -14
  45. package/src/client/components/PersonPanel.svelte +66 -5
  46. package/src/client/components/redaction.ts +51 -0
  47. package/src/client/messages.ts +346 -77
  48. package/src/client/pages/AttendancePage.svelte +24 -1
  49. package/src/client/pages/DirectoryPage.svelte +285 -11
  50. package/src/client/pages/OfficesPage.svelte +6 -0
  51. package/src/client/pages/OrgPage.svelte +7 -0
  52. package/src/client/permissions.ts +12 -4
  53. package/src/client/settings/AccrualSettings.svelte +715 -95
  54. package/src/client/settings/GeneralSettings.svelte +12 -9
  55. package/src/client/settings/SchedulesSettings.svelte +31 -78
  56. package/src/contract/attendance.ts +27 -1
  57. package/src/contract/capabilities.ts +10 -0
  58. package/src/contract/events.ts +23 -19
  59. package/src/contract/models.ts +11 -0
  60. package/src/contract/router.ts +11 -3
  61. package/src/contract/settings.ts +11 -5
@@ -1,12 +1,13 @@
1
1
  import { KernError, requires, requiresCapability, uuidv7, workspaceScoped, } from '@kernhq/kernel';
2
2
  import { implement } from '@orpc/server';
3
- import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, or, sql } from 'drizzle-orm';
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';
5
5
  import { AccrualConfig, CarryForwardConfig, OvertimeConfig, RoundingConfig, WorkingTimeConfig, } from '../contract/policies.js';
6
6
  import { accrueForPeriod } from '../policy/accrual.js';
7
7
  import { countWorkingDays, workingDays } from '../policy/calendar.js';
8
8
  import { COUNTRY_PACKS, packDays, packFor } from './packs/index.js';
9
9
  import { approvalChains, approvalDecisions, approvalRequests, approvalSteps, attendanceDays, calendarDays, calendars, costCenters, customFieldDefs, delegations, employments, leaveLedger, leaveRequestDays, leaveRequests, leaveTypes, legalEntities, officeAssignments, offices, orgUnits, people, peopleSensitive, periods, personDocuments, personHistory, policies, policyAssignments, positions, punches, regularizations, scheduleAssignments, schedules, } from './schema.js';
10
+ import { forViewer, HrAccessService, seesRecordOf, visibleSet } from './services/access.js';
10
11
  import { ApprovalService } from './services/approvals.js';
11
12
  import { AttendanceService } from './services/attendance.js';
12
13
  import { inForceOn, todayIso } from './services/db.js';
@@ -15,6 +16,74 @@ import { PeopleService } from './services/people.js';
15
16
  import { hashConfig, PolicyService } from './services/policies.js';
16
17
  import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
17
18
  const os = implement(hrContract).$context();
19
+ const encodeCursor = (key, id) => Buffer.from(JSON.stringify([key, id]), 'utf8').toString('base64url');
20
+ function decodeCursor(raw) {
21
+ if (!raw)
22
+ return null;
23
+ try {
24
+ const [key, id] = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
25
+ if (typeof key !== 'string' || typeof id !== 'string')
26
+ throw new Error('malformed cursor');
27
+ return { key, id };
28
+ }
29
+ catch {
30
+ // A cursor is opaque to the caller, so a broken one is a tampered URL or a bug on our side —
31
+ // never something the reader can fix by asking again. Refusing beats quietly serving page one,
32
+ // which reads as a list that jumps back to the top for no reason anybody can see.
33
+ throw KernError.badRequest('That page cursor is not valid.');
34
+ }
35
+ }
36
+ /**
37
+ * `(sort, id) > (…)` as one row comparison rather than `sort > k or (sort = k and id > i)`:
38
+ * Postgres can drive a row comparison straight off an index on `(sort, id)` and cannot do that with
39
+ * the `or` spelling. Both halves of the cursor are cast to the column's own type, because an
40
+ * untyped bind parameter beside a `date` or a `timestamptz` is not something to make the planner
41
+ * guess at.
42
+ */
43
+ const after = (sort, id, dir, c) => {
44
+ const from = sql `(${c.key}::${sql.raw(sort.getSQLType())}, ${c.id}::uuid)`;
45
+ return dir === 'asc' ? sql `(${sort}, ${id}) > ${from}` : sql `(${sort}, ${id}) < ${from}`;
46
+ };
47
+ /**
48
+ * Did this error come from one named unique index?
49
+ *
50
+ * The cause chain is walked rather than the error itself, because drizzle wraps what the driver
51
+ * threw and the `code` a duplicate key arrives as — `23505` — is on the pg error underneath. The
52
+ * index name is checked too: a handler that treats *any* duplicate key as its own idempotency
53
+ * replay would silently swallow a collision on some other constraint, which is a bug reported as a
54
+ * success.
55
+ */
56
+ function isUniqueViolation(err, constraint) {
57
+ for (let e = err, depth = 0; e && depth < 5; e = e.cause, depth++) {
58
+ const pg = e;
59
+ if (pg.code === '23505' && pg.constraint === constraint)
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ /**
65
+ * Cut a page out of the `limit + 1` rows the query asked for.
66
+ *
67
+ * The extra row is how "there is more" is known without a second count, and why a page that happens
68
+ * to fill exactly does not advertise a next page that turns out to be empty.
69
+ *
70
+ * **The key is `string`, never a `Date`, and that is load-bearing.** A `timestamptz` is stored at
71
+ * microsecond precision and node-postgres hands it back as a JS `Date`, which is milliseconds — so
72
+ * a cursor built from the row object is *strictly less* than the value it came from, and
73
+ * `(at, id) < (key, id)` then excludes every row that ties with the last row of the page. That is
74
+ * silent: no error, the list just ends early, and it bites hardest exactly where the id tiebreaker
75
+ * was supposed to save it — one edit writing several rows in a transaction, all sharing `now()`.
76
+ * Measured on `person_history`: five rows in one statement, page size two, page two returned none
77
+ * of the remaining three. A timestamp cursor therefore has to select the value `::text` and pass
78
+ * that, which the type here forces rather than trusts.
79
+ */
80
+ function paginate(rows, limit, cursorOf) {
81
+ if (rows.length <= limit)
82
+ return { items: rows, nextCursor: null };
83
+ const items = rows.slice(0, limit);
84
+ const [key, id] = cursorOf(items[items.length - 1]);
85
+ return { items, nextCursor: encodeCursor(key, id) };
86
+ }
18
87
  /**
19
88
  * The router.
20
89
  *
@@ -32,6 +101,7 @@ export function implement_(kernel) {
32
101
  const cap = (id) => requiresCapability(MODULE_ID, id);
33
102
  const resolve = new ResolveService();
34
103
  const svc = new PeopleService(kernel);
104
+ const access = new HrAccessService(kernel);
35
105
  const ledger = new LedgerService();
36
106
  const approvals = new ApprovalService(kernel);
37
107
  const policySvc = new PolicyService(resolve);
@@ -42,7 +112,18 @@ export function implement_(kernel) {
42
112
  return os.router({
43
113
  // ================================================================= people
44
114
  people: {
45
- list: scoped.people.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
115
+ /**
116
+ * The directory: every person in the workspace, and as much of each as the reader may have.
117
+ *
118
+ * `hr.person.view` is a `member` default and stays one — a staff directory a colleague cannot
119
+ * open is a worse product, not a safer one. What the three widening keys decide is how much
120
+ * of each row comes back: `HrAccessService` resolves the people whose *personnel record* this
121
+ * reader may see, and everybody else arrives as a card with the four personnel fields nulled.
122
+ * The filters, the count and the cursor are unaffected — the page is the same page for
123
+ * everybody, which is what keeps `total` honest.
124
+ */
125
+ list: scoped.people.list.use(requires('hr.person.view')).handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
126
+ const visible = visibleSet(await access.visiblePersonIds(tx, input.workspaceId, context.principal));
46
127
  const where = [eq(people.workspaceId, input.workspaceId)];
47
128
  if (input.q)
48
129
  where.push(ilike(people.displayName, `%${input.q}%`));
@@ -72,16 +153,21 @@ export function implement_(kernel) {
72
153
  ? inArray(people.id, holders.map((r) => r.personId))
73
154
  : sql `false`);
74
155
  }
75
- const rows = await tx
76
- .select()
77
- .from(people)
78
- .where(and(...where))
79
- .orderBy(asc(people.displayName))
80
- .limit(input.limit);
156
+ // The count is of everything the filters match, so it is taken before the cursor narrows
157
+ // the set — `total` is the size of the directory, not of the page being looked at.
81
158
  const [total] = await tx
82
159
  .select({ n: count() })
83
160
  .from(people)
84
161
  .where(and(...where));
162
+ const cursor = decodeCursor(input.cursor);
163
+ if (cursor)
164
+ where.push(after(people.displayName, people.id, 'asc', cursor));
165
+ const { items: rows, nextCursor } = paginate(await tx
166
+ .select()
167
+ .from(people)
168
+ .where(and(...where))
169
+ .orderBy(asc(people.displayName), asc(people.id))
170
+ .limit(input.limit + 1), input.limit, (r) => [r.displayName, r.id]);
85
171
  // One query for the whole page rather than a resolution per row: a directory of five
86
172
  // hundred people would otherwise be five hundred ladder walks.
87
173
  const assignments = rows.length
@@ -97,21 +183,29 @@ export function implement_(kernel) {
97
183
  : [];
98
184
  const officeBy = new Map(assignments.map((a) => [a.personId, a]));
99
185
  return {
100
- items: rows.map((r) => ({
186
+ items: rows.map((r) => forViewer({
101
187
  ...PeopleService.toPerson(r),
102
188
  // Spreading into a fresh literal drops the branded WorkspaceId that flowed through
103
189
  // `toPerson`, so it is restored rather than widened to `string`.
104
190
  workspaceId: r.workspaceId,
105
191
  officeId: officeBy.get(r.id)?.officeId ?? null,
106
192
  officeName: officeBy.get(r.id)?.name ?? null,
107
- })),
108
- nextCursor: null,
193
+ }, visible)),
194
+ nextCursor,
109
195
  total: total?.n ?? 0,
110
196
  };
111
197
  })),
112
- get: scoped.people.get
113
- .use(requires('hr.person.view'))
114
- .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => PeopleService.toPerson(await svc.load(tx, input.workspaceId, input.personId)))),
198
+ /**
199
+ * One person, at the width `people.list` would have shown them.
200
+ *
201
+ * Not a 404 for somebody outside the reader's record scope: the person exists, the directory
202
+ * says so, and answering "no such person" to a colleague looking up a work email would be a
203
+ * lie the whole product contradicts. They get the card.
204
+ */
205
+ get: scoped.people.get.use(requires('hr.person.view')).handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
206
+ 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);
208
+ })),
115
209
  /**
116
210
  * No permission check: everybody may read their own record, and a permission nobody can lack
117
211
  * is noise in the role editor. Returns null rather than 404 when the signed-in user has no HR
@@ -186,7 +280,15 @@ export function implement_(kernel) {
186
280
  await changed(workspaceId, 'person', personId, 'updated');
187
281
  return PeopleService.toPerson(row.updated);
188
282
  }),
189
- /** Ends employment and keeps the record. A terminated person is history, not a deletion. */
283
+ /**
284
+ * Ends employment and keeps the record. A terminated person is history, not a deletion.
285
+ *
286
+ * The reason the dialog collects is written to `person_history` rather than to a column on
287
+ * `people`: "why did she leave" is a fact about the *event*, it is asked for by an audit
288
+ * alongside who ended the employment and when, and `person_history` is the append-only table
289
+ * that already answers exactly that question. It used to be accepted and dropped on the
290
+ * floor, which is the one thing an offboarding record must not do.
291
+ */
190
292
  offboard: scoped.people.offboard
191
293
  .use(requires('hr.person.manage'))
192
294
  .handler(async ({ input, context }) => {
@@ -205,9 +307,13 @@ export function implement_(kernel) {
205
307
  .update(officeAssignments)
206
308
  .set({ effectiveTo: input.on })
207
309
  .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.personId, input.personId), isNull(officeAssignments.effectiveTo)));
310
+ const reason = input.reason?.trim();
208
311
  await svc.record(tx, input.workspaceId, input.personId, context.principal.userId ?? null, [
209
312
  { field: 'status', from: before.status, to: 'terminated' },
210
313
  { field: 'terminatedOn', from: before.terminatedOn, to: input.on },
314
+ // Only when there is one. A row saying the reason changed from nothing to nothing is
315
+ // noise in the trail somebody reads to find out what happened.
316
+ ...(reason ? [{ field: 'terminationReason', from: null, to: reason }] : []),
211
317
  ]);
212
318
  return { before, updated: updated };
213
319
  });
@@ -221,13 +327,38 @@ export function implement_(kernel) {
221
327
  await changed(input.workspaceId, 'person', input.personId, 'updated');
222
328
  return PeopleService.toPerson(row.updated);
223
329
  }),
224
- history: scoped.people.history.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
225
- const rows = await tx
226
- .select()
330
+ /**
331
+ * The audit trail, and the reason redacting the record alone would have been theatre.
332
+ *
333
+ * Every row carries the old and new *value* of a field — so `personalEmail`, `phone` and
334
+ * `hiredOn` are all in here, in plain sight, for anybody who can list the history. It is a
335
+ * personnel read however narrow the query looks, and it is refused for somebody whose record
336
+ * the reader may not see. No permission is named on the refusal: three different keys would
337
+ * each have opened it, and naming one of them would send the reader to ask for the wrong one.
338
+ */
339
+ history: scoped.people.history.use(requires('hr.person.view')).handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
340
+ const visible = visibleSet(await access.visiblePersonIds(tx, input.workspaceId, context.principal));
341
+ if (!seesRecordOf(visible, input.personId))
342
+ throw KernError.forbidden();
343
+ const where = [
344
+ eq(personHistory.workspaceId, input.workspaceId),
345
+ eq(personHistory.personId, input.personId),
346
+ ];
347
+ // The id tiebreaker earns its keep here more than anywhere: one edit writes several rows
348
+ // in one statement, and `now()` is frozen for a transaction, so they all share an `at`.
349
+ const cursor = decodeCursor(input.cursor);
350
+ if (cursor)
351
+ where.push(after(personHistory.at, personHistory.id, 'desc', cursor));
352
+ const { items: rows, nextCursor } = paginate(
353
+ // `atText` rather than the `Date`: see `paginate`. `now()` is frozen for a transaction,
354
+ // so a single edit's rows all share an `at` to the microsecond, and a millisecond cursor
355
+ // would drop every one of them after the first page.
356
+ await tx
357
+ .select({ ...getTableColumns(personHistory), atText: sql `${personHistory.at}::text` })
227
358
  .from(personHistory)
228
- .where(and(eq(personHistory.workspaceId, input.workspaceId), eq(personHistory.personId, input.personId)))
229
- .orderBy(desc(personHistory.at))
230
- .limit(input.limit);
359
+ .where(and(...where))
360
+ .orderBy(desc(personHistory.at), desc(personHistory.id))
361
+ .limit(input.limit + 1), input.limit, (r) => [r.atText, r.id]);
231
362
  return {
232
363
  items: rows.map((r) => ({
233
364
  id: r.id,
@@ -238,7 +369,7 @@ export function implement_(kernel) {
238
369
  actorId: r.actorId,
239
370
  source: r.source,
240
371
  })),
241
- nextCursor: null,
372
+ nextCursor,
242
373
  };
243
374
  })),
244
375
  sensitive: {
@@ -678,37 +809,61 @@ export function implement_(kernel) {
678
809
  await changed(input.workspaceId, 'office', row.id, 'updated');
679
810
  return toOffice(row);
680
811
  }),
812
+ /**
813
+ * The office's people, and its headcount.
814
+ *
815
+ * One join rather than the two round trips this used to be. The old shape limited the
816
+ * *assignments* and then sorted the people it had fetched, so a page was an arbitrary subset
817
+ * put in alphabetical order — and it reported `rows.length` as `total`, which told an office
818
+ * of forty that it had twenty as soon as one page stopped holding everybody. The headcount is
819
+ * counted now, over the same predicate and without the cursor.
820
+ */
681
821
  people: scoped.offices.people
682
822
  .use(cap('offices'))
683
823
  .use(requires('hr.office.view'))
684
- .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
824
+ .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
825
+ // An office roster is the directory filtered to one place, so it is read at the same
826
+ // width — `hr.office.view` is a `member` default too, and returning whole records here
827
+ // would be a way round `people.list` rather than a different question.
828
+ const visible = visibleSet(await access.visiblePersonIds(tx, input.workspaceId, context.principal));
829
+ const here = and(eq(people.id, officeAssignments.personId), eq(people.workspaceId, officeAssignments.workspaceId));
830
+ // As of today, not "the row with no end date" — the same predicate `HrAccessService`
831
+ // uses, and they have to agree because both answer this one screen. An office move and
832
+ // an offboarding are both *dated*: `people.offboard` writes a last working day that is
833
+ // normally in the future, so a null-end-date test drops somebody the moment their
834
+ // leaving is recorded, while a future-dated transfer adds them weeks early. The roster
835
+ // showed one set and the record scope another, on the same response.
685
836
  const where = [
686
837
  eq(officeAssignments.workspaceId, input.workspaceId),
687
838
  eq(officeAssignments.officeId, input.officeId),
688
- isNull(officeAssignments.effectiveTo),
839
+ inForceOn(officeAssignments.effectiveFrom, officeAssignments.effectiveTo, todayIso()),
689
840
  ];
690
841
  if (input.primaryOnly)
691
842
  where.push(eq(officeAssignments.isPrimary, true));
692
- const assignments = await tx
693
- .select()
843
+ const [total] = await tx
844
+ .select({ n: count() })
694
845
  .from(officeAssignments)
846
+ .innerJoin(people, here)
847
+ .where(and(...where));
848
+ const cursor = decodeCursor(input.cursor);
849
+ if (cursor)
850
+ where.push(after(people.displayName, people.id, 'asc', cursor));
851
+ const { items: rows, nextCursor } = paginate(await tx
852
+ .select({ person: people, isPrimary: officeAssignments.isPrimary })
853
+ .from(officeAssignments)
854
+ .innerJoin(people, here)
695
855
  .where(and(...where))
696
- .limit(input.limit);
697
- if (!assignments.length)
698
- return { items: [], nextCursor: null, total: 0 };
699
- const rows = await tx
700
- .select()
701
- .from(people)
702
- .where(and(eq(people.workspaceId, input.workspaceId), inArray(people.id, assignments.map((a) => a.personId))))
703
- .orderBy(asc(people.displayName));
704
- const primaryHere = new Set(assignments.filter((a) => a.isPrimary).map((a) => a.personId));
856
+ .orderBy(asc(people.displayName), asc(people.id))
857
+ .limit(input.limit + 1), input.limit, (r) => [r.person.displayName, r.person.id]);
705
858
  return {
706
- items: rows.map((r) => ({
707
- ...PeopleService.toPerson(r),
708
- isPrimaryHere: primaryHere.has(r.id),
709
- })),
710
- nextCursor: null,
711
- total: rows.length,
859
+ items: rows.map((r) => forViewer({
860
+ ...PeopleService.toPerson(r.person),
861
+ // Migration 0001 allows one open assignment per person per office, so the join
862
+ // cannot produce a person twice and this flag cannot disagree with itself.
863
+ isPrimaryHere: r.isPrimary,
864
+ }, visible)),
865
+ nextCursor,
866
+ total: total?.n ?? 0,
712
867
  };
713
868
  })),
714
869
  assign: scoped.offices.assign
@@ -1444,13 +1599,16 @@ export function implement_(kernel) {
1444
1599
  const where = [eq(periods.workspaceId, input.workspaceId)];
1445
1600
  if (input.kind)
1446
1601
  where.push(eq(periods.kind, input.kind));
1447
- const rows = await tx
1602
+ const cursor = decodeCursor(input.cursor);
1603
+ if (cursor)
1604
+ where.push(after(periods.startsOn, periods.id, 'desc', cursor));
1605
+ const { items, nextCursor } = paginate(await tx
1448
1606
  .select()
1449
1607
  .from(periods)
1450
1608
  .where(and(...where))
1451
- .orderBy(desc(periods.startsOn))
1452
- .limit(input.limit);
1453
- return { items: rows.map(toPeriod), nextCursor: null };
1609
+ .orderBy(desc(periods.startsOn), desc(periods.id))
1610
+ .limit(input.limit + 1), input.limit, (r) => [r.startsOn, r.id]);
1611
+ return { items: items.map(toPeriod), nextCursor };
1454
1612
  })),
1455
1613
  create: scoped.periods.create
1456
1614
  .use(cap('periods'))
@@ -1616,13 +1774,21 @@ export function implement_(kernel) {
1616
1774
  ];
1617
1775
  if (!input.includeVoided)
1618
1776
  where.push(isNull(punches.voidedByPunchId));
1619
- const rows = await tx
1620
- .select()
1777
+ const cursor = decodeCursor(input.cursor);
1778
+ if (cursor)
1779
+ where.push(after(punches.at, punches.id, 'asc', cursor));
1780
+ const { items, nextCursor } = paginate(
1781
+ // `atText`, for the reason in `paginate`. Every insert here supplies a JS `Date`, so
1782
+ // nothing sub-millisecond is stored today and the bug is latent rather than live —
1783
+ // but the column carries `.defaultNow()`, so one insert that omits `at` would start
1784
+ // dropping punches off the end of a page with nothing to show for it.
1785
+ await tx
1786
+ .select({ ...getTableColumns(punches), atText: sql `${punches.at}::text` })
1621
1787
  .from(punches)
1622
1788
  .where(and(...where))
1623
- .orderBy(asc(punches.at))
1624
- .limit(input.limit);
1625
- return { items: rows.map(toPunch), nextCursor: null };
1789
+ .orderBy(asc(punches.at), asc(punches.id))
1790
+ .limit(input.limit + 1), input.limit, (r) => [r.atText, r.id]);
1791
+ return { items: items.map(toPunch), nextCursor };
1626
1792
  })),
1627
1793
  void: scoped.attendance.punches.void
1628
1794
  .use(cap('attendance'))
@@ -1669,13 +1835,16 @@ export function implement_(kernel) {
1669
1835
  const personId = await personFor(tx, input.workspaceId, context, input.personId);
1670
1836
  where.push(eq(attendanceDays.personId, personId));
1671
1837
  }
1672
- const rows = await tx
1838
+ const cursor = decodeCursor(input.cursor);
1839
+ if (cursor)
1840
+ where.push(after(attendanceDays.businessDate, attendanceDays.id, 'asc', cursor));
1841
+ const { items, nextCursor } = paginate(await tx
1673
1842
  .select()
1674
1843
  .from(attendanceDays)
1675
1844
  .where(and(...where))
1676
- .orderBy(asc(attendanceDays.businessDate))
1677
- .limit(input.limit);
1678
- return { items: rows.map(toAttendanceDay), nextCursor: null };
1845
+ .orderBy(asc(attendanceDays.businessDate), asc(attendanceDays.id))
1846
+ .limit(input.limit + 1), input.limit, (r) => [r.businessDate, r.id]);
1847
+ return { items: items.map(toAttendanceDay), nextCursor };
1679
1848
  })),
1680
1849
  recompute: scoped.attendance.days.recompute
1681
1850
  .use(cap('attendance'))
@@ -1813,19 +1982,22 @@ export function implement_(kernel) {
1813
1982
  ];
1814
1983
  if (input.status?.length)
1815
1984
  where.push(inArray(regularizations.status, input.status));
1816
- const rows = await tx
1985
+ const cursor = decodeCursor(input.cursor);
1986
+ if (cursor)
1987
+ where.push(after(regularizations.businessDate, regularizations.id, 'desc', cursor));
1988
+ const { items, nextCursor } = paginate(await tx
1817
1989
  .select()
1818
1990
  .from(regularizations)
1819
1991
  .where(and(...where))
1820
- .orderBy(desc(regularizations.businessDate))
1821
- .limit(input.limit);
1822
- return { items: rows.map(toRegularization), nextCursor: null };
1992
+ .orderBy(desc(regularizations.businessDate), desc(regularizations.id))
1993
+ .limit(input.limit + 1), input.limit, (r) => [r.businessDate, r.id]);
1994
+ return { items: items.map(toRegularization), nextCursor };
1823
1995
  })),
1824
1996
  request: scoped.attendance.regularizations.request
1825
1997
  .use(cap('attendance'))
1826
1998
  .use(requires('hr.attendance.punch'))
1827
1999
  .handler(async ({ input, context }) => {
1828
- const row = await db.withWorkspace(input.workspaceId, async (tx) => {
2000
+ const filed = await db.withWorkspace(input.workspaceId, async (tx) => {
1829
2001
  const personId = await personFor(tx, input.workspaceId, context, input.personId);
1830
2002
  const [created] = await tx
1831
2003
  .insert(regularizations)
@@ -1861,10 +2033,22 @@ export function implement_(kernel) {
1861
2033
  .select()
1862
2034
  .from(regularizations)
1863
2035
  .where(eq(regularizations.id, created.id));
1864
- return fresh;
2036
+ // Same reason as leave: the approvers are told after this commits, never inside it.
2037
+ return {
2038
+ row: fresh,
2039
+ approval: { requestId: raised.request.id, approverIds: raised.firstStepApprovers },
2040
+ };
1865
2041
  });
1866
- await changed(input.workspaceId, 'regularization', row.id, 'created');
1867
- return toRegularization(row);
2042
+ if (filed.approval.approverIds.length)
2043
+ await kernel.emit(hrEvents.approvalRequested, {
2044
+ requestId: filed.approval.requestId,
2045
+ workspaceId: input.workspaceId,
2046
+ subjectType: 'regularization',
2047
+ subjectId: filed.row.id,
2048
+ approverIds: filed.approval.approverIds,
2049
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
2050
+ await changed(input.workspaceId, 'regularization', filed.row.id, 'created');
2051
+ return toRegularization(filed.row);
1868
2052
  }),
1869
2053
  },
1870
2054
  },
@@ -1971,13 +2155,19 @@ export function implement_(kernel) {
1971
2155
  where.push(eq(leaveLedger.leaveTypeId, input.leaveTypeId));
1972
2156
  if (input.periodYear)
1973
2157
  where.push(eq(leaveLedger.periodYear, input.periodYear));
1974
- const rows = await tx
2158
+ const cursor = decodeCursor(input.cursor);
2159
+ if (cursor)
2160
+ where.push(after(leaveLedger.effectiveOn, leaveLedger.id, 'desc', cursor));
2161
+ // The second sort key is the id rather than `created_at`, and means the same thing:
2162
+ // ids here are uuidv7, so they already run in creation order — and unlike `created_at`
2163
+ // no two rows can share one, which is what makes the cursor land in exactly one place.
2164
+ const { items, nextCursor } = paginate(await tx
1975
2165
  .select()
1976
2166
  .from(leaveLedger)
1977
2167
  .where(and(...where))
1978
- .orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.createdAt))
1979
- .limit(input.limit);
1980
- return { items: rows.map(toLedgerEntry), nextCursor: null };
2168
+ .orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.id))
2169
+ .limit(input.limit + 1), input.limit, (r) => [r.effectiveOn, r.id]);
2170
+ return { items: items.map(toLedgerEntry), nextCursor };
1981
2171
  })),
1982
2172
  },
1983
2173
  adjust: scoped.leave.adjust
@@ -2013,11 +2203,34 @@ export function implement_(kernel) {
2013
2203
  .use(requires('hr.leave.view'))
2014
2204
  .handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
2015
2205
  const where = [eq(leaveRequests.workspaceId, input.workspaceId)];
2016
- if (input.personId)
2206
+ // An office is other people's absences, so asking for one costs the same permission
2207
+ // the team calendar costs. `hr.leave.view` alone must not become a way of reading the
2208
+ // whole company — which is exactly what a filter the server ignores had made of it.
2209
+ if (input.officeId) {
2210
+ await kernel.authz.require(context.principal, 'hr.leave.view_team', {
2211
+ kind: 'workspace',
2212
+ id: input.workspaceId,
2213
+ workspaceId: input.workspaceId,
2214
+ });
2215
+ const here = await tx
2216
+ .select({ personId: officeAssignments.personId })
2217
+ .from(officeAssignments)
2218
+ .where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
2219
+ // An empty office matches nobody, not everybody.
2220
+ where.push(here.length
2221
+ ? inArray(leaveRequests.personId, here.map((h) => h.personId))
2222
+ : sql `false`);
2223
+ }
2224
+ if (input.personId) {
2225
+ // Naming somebody else is the same act as naming their office, and was the one way
2226
+ // through this handler that cost nothing: `hr.leave.view` and a person id read
2227
+ // anybody's absences. `personFor` is the rule everywhere else in this module.
2228
+ await personFor(tx, input.workspaceId, context, input.personId);
2017
2229
  where.push(eq(leaveRequests.personId, input.personId));
2018
- else if (!context.principal.instanceAdmin) {
2019
- // Without an explicit person, this is "my requests". Seeing everybody's by default
2020
- // would leak the whole company's absences to any member with hr.leave.view.
2230
+ }
2231
+ else if (!input.officeId && !context.principal.instanceAdmin) {
2232
+ // Without an explicit person or office, this is "my requests". Seeing everybody's by
2233
+ // default would leak the whole company's absences to any member with hr.leave.view.
2021
2234
  const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
2022
2235
  where.push(me ? eq(leaveRequests.personId, me.id) : sql `false`);
2023
2236
  }
@@ -2027,13 +2240,16 @@ export function implement_(kernel) {
2027
2240
  where.push(gte(leaveRequests.endsOn, input.from));
2028
2241
  if (input.to)
2029
2242
  where.push(lte(leaveRequests.startsOn, input.to));
2030
- const rows = await tx
2243
+ const cursor = decodeCursor(input.cursor);
2244
+ if (cursor)
2245
+ where.push(after(leaveRequests.startsOn, leaveRequests.id, 'desc', cursor));
2246
+ const { items, nextCursor } = paginate(await tx
2031
2247
  .select()
2032
2248
  .from(leaveRequests)
2033
2249
  .where(and(...where))
2034
- .orderBy(desc(leaveRequests.startsOn))
2035
- .limit(input.limit);
2036
- return { items: rows.map(toLeaveRequest), nextCursor: null };
2250
+ .orderBy(desc(leaveRequests.startsOn), desc(leaveRequests.id))
2251
+ .limit(input.limit + 1), input.limit, (r) => [r.startsOn, r.id]);
2252
+ return { items: items.map(toLeaveRequest), nextCursor };
2037
2253
  })),
2038
2254
  get: scoped.leave.requests.get
2039
2255
  .use(cap('leave'))
@@ -2046,11 +2262,31 @@ export function implement_(kernel) {
2046
2262
  const personId = await personFor(tx, input.workspaceId, context, input.personId);
2047
2263
  return simulate(tx, input.workspaceId, personId, input);
2048
2264
  })),
2265
+ /**
2266
+ * File a request.
2267
+ *
2268
+ * `idempotencyKey` is the contract's promise that a retried submission is safe, and the
2269
+ * server used to store the key without ever reading it. So a retry filed a second request,
2270
+ * or died on whichever unique index it reached first — the exploded days for a counted
2271
+ * absence, the key itself otherwise. Neither is what the caller was promised.
2272
+ *
2273
+ * `hr_leave_requests_idem_uq` is what there is to lean on: it already refuses a second row
2274
+ * for a key, so honouring the promise is a read before the insert and an answer for the
2275
+ * loser of the race.
2276
+ */
2049
2277
  create: scoped.leave.requests.create
2050
2278
  .use(cap('leave'))
2051
2279
  .use(requires('hr.leave.request'))
2052
2280
  .handler(async ({ input, context }) => {
2053
- const result = await db.withWorkspace(input.workspaceId, async (tx) => {
2281
+ const key = input.idempotencyKey;
2282
+ // Held as a promise rather than awaited here, so that the duplicate recovery below is
2283
+ // one step at the end instead of ninety lines wrapped in a `try`.
2284
+ const filing = db.withWorkspace(input.workspaceId, async (tx) => {
2285
+ // Before anything is locked or simulated: the same key is the same submission, and
2286
+ // answering it with the request it already filed is what "safe to retry" means.
2287
+ const already = key ? await byIdempotencyKey(tx, input.workspaceId, key) : undefined;
2288
+ if (already)
2289
+ return { request: already, personId: already.personId, replay: true };
2054
2290
  const personId = await personFor(tx, input.workspaceId, context, input.personId);
2055
2291
  // Everything that spends balance takes the cursor lock first, inside this
2056
2292
  // transaction. Two overlapping requests for the last day cannot both read "enough".
@@ -2114,8 +2350,34 @@ export function implement_(kernel) {
2114
2350
  if (raised.autoApproved)
2115
2351
  await applyApproval(tx, input.workspaceId, request.id, context.principal.userId ?? null);
2116
2352
  const [fresh] = await tx.select().from(leaveRequests).where(eq(leaveRequests.id, request.id));
2117
- return { request: fresh, approvers: raised.firstStepApprovers, personId };
2353
+ // Carried out of the transaction rather than emitted here: an approver must not be
2354
+ // handed a card for a request a rollback is about to erase. `firstStepApprovers` is
2355
+ // empty exactly when the chain resolved to nobody — the auto-approval above.
2356
+ return {
2357
+ request: fresh,
2358
+ personId,
2359
+ replay: false,
2360
+ approval: { requestId: raised.request.id, approverIds: raised.firstStepApprovers },
2361
+ };
2118
2362
  });
2363
+ const result = await filing.catch(async (err) => {
2364
+ // The other half of a double submit: two clicks a browser sends before the first has
2365
+ // answered both pass the read above, both reach the insert, and the unique index
2366
+ // refuses one. The loser lost a race it was never meant to enter, so it is answered
2367
+ // with the request that won rather than with a constraint error. The re-read needs a
2368
+ // new transaction — the failed one is aborted and will not answer another query.
2369
+ if (!key || !isUniqueViolation(err, 'hr_leave_requests_idem_uq'))
2370
+ throw err;
2371
+ const won = await db.withWorkspace(input.workspaceId, (tx) => byIdempotencyKey(tx, input.workspaceId, key));
2372
+ if (!won)
2373
+ throw err;
2374
+ return { request: won, personId: won.personId, replay: true };
2375
+ });
2376
+ // A replay files nothing, so it announces nothing. Emitting `leaveRequested` again would
2377
+ // put a second card in an approver's inbox for one request — the outcome the key exists
2378
+ // to prevent, arriving by a different route.
2379
+ if (result.replay)
2380
+ return toLeaveRequest(result.request);
2119
2381
  await kernel.emit(hrEvents.leaveRequested, {
2120
2382
  requestId: result.request.id,
2121
2383
  workspaceId: input.workspaceId,
@@ -2123,6 +2385,18 @@ export function implement_(kernel) {
2123
2385
  startsOn: input.startsOn,
2124
2386
  endsOn: input.endsOn,
2125
2387
  }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
2388
+ // Second, and only when somebody is actually waiting: the request exists, and these are
2389
+ // the people the *first* step is on. Nothing for a chain that resolved to nobody — that
2390
+ // was approved on the way in and is not waiting on anyone. The ids are person ids, the
2391
+ // same identity the rest of `hr.*` carries.
2392
+ if (result.approval.approverIds.length)
2393
+ await kernel.emit(hrEvents.approvalRequested, {
2394
+ requestId: result.approval.requestId,
2395
+ workspaceId: input.workspaceId,
2396
+ subjectType: 'leave',
2397
+ subjectId: result.request.id,
2398
+ approverIds: result.approval.approverIds,
2399
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
2126
2400
  await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
2127
2401
  return toLeaveRequest(result.request);
2128
2402
  }),
@@ -2236,6 +2510,13 @@ export function implement_(kernel) {
2236
2510
  /**
2237
2511
  * Everything waiting on the caller. No permission: an inbox of what *you* must decide is
2238
2512
  * yours by definition, and the engine only lists steps you are named on.
2513
+ *
2514
+ * The one paged list here that still answers `nextCursor: null`, and the only one that cannot
2515
+ * be fixed from this file: the page is cut inside `ApprovalService.inboxFor`, which takes a
2516
+ * limit and no cursor. Honouring one means widening that signature and pushing
2517
+ * `after(approvalRequests.requestedAt, approvalRequests.id, 'desc', …)` into its final query —
2518
+ * ordering it by `(requested_at, id)` on the way, so the cursor has something unique to land
2519
+ * on. Filtering the rows it returns would not do: it has already truncated them.
2239
2520
  */
2240
2521
  inbox: scoped.approvals.inbox.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
2241
2522
  const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
@@ -2454,6 +2735,14 @@ export function implement_(kernel) {
2454
2735
  },
2455
2736
  // ================================================================= custom fields
2456
2737
  fields: {
2738
+ /**
2739
+ * Deliberately not narrowed by the record scope, unlike everything else on `hr.person.view`.
2740
+ *
2741
+ * These are field *definitions* — a workspace's shape, not anybody's data. Every screen that
2742
+ * renders a person has to know what "hire_buddy" is called and whether it is a date before it
2743
+ * can draw a single row, and a reader who may see one person's record needs the whole schema
2744
+ * to read that one person. There is nothing here to scope to a person.
2745
+ */
2457
2746
  list: scoped.fields.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
2458
2747
  const where = [eq(customFieldDefs.workspaceId, input.workspaceId)];
2459
2748
  if (!input.includeArchived)
@@ -2774,6 +3063,15 @@ export function implement_(kernel) {
2774
3063
  });
2775
3064
  return personId;
2776
3065
  }
3066
+ /** The request a retry is a retry *of*, or undefined the first time a key is seen. */
3067
+ async function byIdempotencyKey(tx, workspaceId, key) {
3068
+ const [row] = await tx
3069
+ .select()
3070
+ .from(leaveRequests)
3071
+ .where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.idempotencyKey, key)))
3072
+ .limit(1);
3073
+ return row;
3074
+ }
2777
3075
  async function loadRequest(tx, workspaceId, requestId) {
2778
3076
  const [row] = await tx
2779
3077
  .select()