@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.
- package/dist/contract/attendance.d.ts +35 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +25 -1
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +10 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +17 -16
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +23 -16
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/models.d.ts +1 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +11 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/router.d.ts +12 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +11 -3
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +0 -1
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +11 -5
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +30 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +13 -5
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +12 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +374 -76
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/access.d.ts +176 -0
- package/dist/server/services/access.d.ts.map +1 -0
- package/dist/server/services/access.js +260 -0
- package/dist/server/services/access.js.map +1 -0
- package/dist/server/services/people.d.ts +1 -0
- package/dist/server/services/people.d.ts.map +1 -1
- package/dist/server/services/people.js +3 -0
- package/dist/server/services/people.js.map +1 -1
- package/package.json +1 -1
- package/src/client/components/DayDetail.svelte +32 -14
- package/src/client/components/PersonPanel.svelte +66 -5
- package/src/client/components/redaction.ts +51 -0
- package/src/client/messages.ts +346 -77
- package/src/client/pages/AttendancePage.svelte +24 -1
- package/src/client/pages/DirectoryPage.svelte +285 -11
- package/src/client/pages/OfficesPage.svelte +6 -0
- package/src/client/pages/OrgPage.svelte +7 -0
- package/src/client/permissions.ts +12 -4
- package/src/client/settings/AccrualSettings.svelte +715 -95
- package/src/client/settings/GeneralSettings.svelte +12 -9
- package/src/client/settings/SchedulesSettings.svelte +31 -78
- package/src/contract/attendance.ts +27 -1
- package/src/contract/capabilities.ts +10 -0
- package/src/contract/events.ts +23 -19
- package/src/contract/models.ts +11 -0
- package/src/contract/router.ts +11 -3
- package/src/contract/settings.ts +11 -5
package/dist/server/router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
193
|
+
}, visible)),
|
|
194
|
+
nextCursor,
|
|
109
195
|
total: total?.n ?? 0,
|
|
110
196
|
};
|
|
111
197
|
})),
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
839
|
+
inForceOn(officeAssignments.effectiveFrom, officeAssignments.effectiveTo, todayIso()),
|
|
689
840
|
];
|
|
690
841
|
if (input.primaryOnly)
|
|
691
842
|
where.push(eq(officeAssignments.isPrimary, true));
|
|
692
|
-
const
|
|
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
|
-
.
|
|
697
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
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:
|
|
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
|
|
1620
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1867
|
-
|
|
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
|
|
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.
|
|
1979
|
-
.limit(input.limit);
|
|
1980
|
-
return { items:
|
|
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
|
-
|
|
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
|
-
|
|
2019
|
-
|
|
2020
|
-
//
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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()
|