@kernhq/module-hr 0.13.0 → 0.13.2
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/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/router.d.ts.map +1 -1
- package/dist/server/router.js +276 -61
- package/dist/server/router.js.map +1 -1
- package/package.json +1 -1
- package/src/client/messages.ts +114 -60
- package/src/client/pages/AttendancePage.svelte +24 -1
- package/src/client/pages/DirectoryPage.svelte +240 -10
- package/src/client/pages/OfficesPage.svelte +6 -0
- package/src/client/pages/OrgPage.svelte +7 -0
- package/src/client/settings/AccrualSettings.svelte +119 -38
- package/src/client/settings/SchedulesSettings.svelte +31 -78
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAiBA,eAAO,MAAM,QAAQ;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAiBA,eAAO,MAAM,QAAQ;;;;;iCA4JnB,CAAA;AAkBF,eAAe,QAAQ,CAAA"}
|
package/dist/server/index.js
CHANGED
|
@@ -109,6 +109,36 @@ export const hrModule = defineServerModule({
|
|
|
109
109
|
return row ?? null;
|
|
110
110
|
}),
|
|
111
111
|
},
|
|
112
|
+
/**
|
|
113
|
+
* The same person, the other way round: a `personId` to the account behind it.
|
|
114
|
+
*
|
|
115
|
+
* Every HR event carries a `personId` and none of them carries a `userId`, because a person is
|
|
116
|
+
* HR's noun and an account is core's — so a subscriber that has to act on somebody's *account*
|
|
117
|
+
* (revoke a licence, collect the laptop the asset register says is theirs) has a person id and
|
|
118
|
+
* nothing it can do with one. `person.byUserId` answers the question a module asks when it
|
|
119
|
+
* already knows the account; this answers the one it asks when it has just been told an
|
|
120
|
+
* employment ended.
|
|
121
|
+
*
|
|
122
|
+
* `userId` is nullable and legitimately null twice over: for somebody with no Kern account, and
|
|
123
|
+
* for somebody whose account was removed from the workspace — `core.member.removed` above
|
|
124
|
+
* clears the link on purpose, because an employment record outlives an account. A caller must
|
|
125
|
+
* treat null as "nothing to do", not as an error.
|
|
126
|
+
*/
|
|
127
|
+
'person.get': {
|
|
128
|
+
handler: async (input, { kernel }) => kernel.database.withWorkspace(input.workspaceId, async (tx) => {
|
|
129
|
+
const [row] = await tx
|
|
130
|
+
.select({
|
|
131
|
+
id: people.id,
|
|
132
|
+
userId: people.userId,
|
|
133
|
+
displayName: people.displayName,
|
|
134
|
+
status: people.status,
|
|
135
|
+
})
|
|
136
|
+
.from(people)
|
|
137
|
+
.where(eq(people.id, input.personId))
|
|
138
|
+
.limit(1);
|
|
139
|
+
return row ?? null;
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
112
142
|
},
|
|
113
143
|
subscriptions: {
|
|
114
144
|
/**
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/server/router.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,cAAc,EAMpB,MAAM,gBAAgB,CAAA;
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/server/router.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,cAAc,EAMpB,MAAM,gBAAgB,CAAA;AAmKvB;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+jIxC"}
|
package/dist/server/router.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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';
|
|
@@ -15,6 +15,74 @@ import { PeopleService } from './services/people.js';
|
|
|
15
15
|
import { hashConfig, PolicyService } from './services/policies.js';
|
|
16
16
|
import { DEFAULT_WORKING_WEEK, ResolveService } from './services/resolve.js';
|
|
17
17
|
const os = implement(hrContract).$context();
|
|
18
|
+
const encodeCursor = (key, id) => Buffer.from(JSON.stringify([key, id]), 'utf8').toString('base64url');
|
|
19
|
+
function decodeCursor(raw) {
|
|
20
|
+
if (!raw)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const [key, id] = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
|
|
24
|
+
if (typeof key !== 'string' || typeof id !== 'string')
|
|
25
|
+
throw new Error('malformed cursor');
|
|
26
|
+
return { key, id };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A cursor is opaque to the caller, so a broken one is a tampered URL or a bug on our side —
|
|
30
|
+
// never something the reader can fix by asking again. Refusing beats quietly serving page one,
|
|
31
|
+
// which reads as a list that jumps back to the top for no reason anybody can see.
|
|
32
|
+
throw KernError.badRequest('That page cursor is not valid.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `(sort, id) > (…)` as one row comparison rather than `sort > k or (sort = k and id > i)`:
|
|
37
|
+
* Postgres can drive a row comparison straight off an index on `(sort, id)` and cannot do that with
|
|
38
|
+
* the `or` spelling. Both halves of the cursor are cast to the column's own type, because an
|
|
39
|
+
* untyped bind parameter beside a `date` or a `timestamptz` is not something to make the planner
|
|
40
|
+
* guess at.
|
|
41
|
+
*/
|
|
42
|
+
const after = (sort, id, dir, c) => {
|
|
43
|
+
const from = sql `(${c.key}::${sql.raw(sort.getSQLType())}, ${c.id}::uuid)`;
|
|
44
|
+
return dir === 'asc' ? sql `(${sort}, ${id}) > ${from}` : sql `(${sort}, ${id}) < ${from}`;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Did this error come from one named unique index?
|
|
48
|
+
*
|
|
49
|
+
* The cause chain is walked rather than the error itself, because drizzle wraps what the driver
|
|
50
|
+
* threw and the `code` a duplicate key arrives as — `23505` — is on the pg error underneath. The
|
|
51
|
+
* index name is checked too: a handler that treats *any* duplicate key as its own idempotency
|
|
52
|
+
* replay would silently swallow a collision on some other constraint, which is a bug reported as a
|
|
53
|
+
* success.
|
|
54
|
+
*/
|
|
55
|
+
function isUniqueViolation(err, constraint) {
|
|
56
|
+
for (let e = err, depth = 0; e && depth < 5; e = e.cause, depth++) {
|
|
57
|
+
const pg = e;
|
|
58
|
+
if (pg.code === '23505' && pg.constraint === constraint)
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Cut a page out of the `limit + 1` rows the query asked for.
|
|
65
|
+
*
|
|
66
|
+
* The extra row is how "there is more" is known without a second count, and why a page that happens
|
|
67
|
+
* to fill exactly does not advertise a next page that turns out to be empty.
|
|
68
|
+
*
|
|
69
|
+
* **The key is `string`, never a `Date`, and that is load-bearing.** A `timestamptz` is stored at
|
|
70
|
+
* microsecond precision and node-postgres hands it back as a JS `Date`, which is milliseconds — so
|
|
71
|
+
* a cursor built from the row object is *strictly less* than the value it came from, and
|
|
72
|
+
* `(at, id) < (key, id)` then excludes every row that ties with the last row of the page. That is
|
|
73
|
+
* silent: no error, the list just ends early, and it bites hardest exactly where the id tiebreaker
|
|
74
|
+
* was supposed to save it — one edit writing several rows in a transaction, all sharing `now()`.
|
|
75
|
+
* Measured on `person_history`: five rows in one statement, page size two, page two returned none
|
|
76
|
+
* of the remaining three. A timestamp cursor therefore has to select the value `::text` and pass
|
|
77
|
+
* that, which the type here forces rather than trusts.
|
|
78
|
+
*/
|
|
79
|
+
function paginate(rows, limit, cursorOf) {
|
|
80
|
+
if (rows.length <= limit)
|
|
81
|
+
return { items: rows, nextCursor: null };
|
|
82
|
+
const items = rows.slice(0, limit);
|
|
83
|
+
const [key, id] = cursorOf(items[items.length - 1]);
|
|
84
|
+
return { items, nextCursor: encodeCursor(key, id) };
|
|
85
|
+
}
|
|
18
86
|
/**
|
|
19
87
|
* The router.
|
|
20
88
|
*
|
|
@@ -72,16 +140,21 @@ export function implement_(kernel) {
|
|
|
72
140
|
? inArray(people.id, holders.map((r) => r.personId))
|
|
73
141
|
: sql `false`);
|
|
74
142
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.from(people)
|
|
78
|
-
.where(and(...where))
|
|
79
|
-
.orderBy(asc(people.displayName))
|
|
80
|
-
.limit(input.limit);
|
|
143
|
+
// The count is of everything the filters match, so it is taken before the cursor narrows
|
|
144
|
+
// the set — `total` is the size of the directory, not of the page being looked at.
|
|
81
145
|
const [total] = await tx
|
|
82
146
|
.select({ n: count() })
|
|
83
147
|
.from(people)
|
|
84
148
|
.where(and(...where));
|
|
149
|
+
const cursor = decodeCursor(input.cursor);
|
|
150
|
+
if (cursor)
|
|
151
|
+
where.push(after(people.displayName, people.id, 'asc', cursor));
|
|
152
|
+
const { items: rows, nextCursor } = paginate(await tx
|
|
153
|
+
.select()
|
|
154
|
+
.from(people)
|
|
155
|
+
.where(and(...where))
|
|
156
|
+
.orderBy(asc(people.displayName), asc(people.id))
|
|
157
|
+
.limit(input.limit + 1), input.limit, (r) => [r.displayName, r.id]);
|
|
85
158
|
// One query for the whole page rather than a resolution per row: a directory of five
|
|
86
159
|
// hundred people would otherwise be five hundred ladder walks.
|
|
87
160
|
const assignments = rows.length
|
|
@@ -105,7 +178,7 @@ export function implement_(kernel) {
|
|
|
105
178
|
officeId: officeBy.get(r.id)?.officeId ?? null,
|
|
106
179
|
officeName: officeBy.get(r.id)?.name ?? null,
|
|
107
180
|
})),
|
|
108
|
-
nextCursor
|
|
181
|
+
nextCursor,
|
|
109
182
|
total: total?.n ?? 0,
|
|
110
183
|
};
|
|
111
184
|
})),
|
|
@@ -186,7 +259,15 @@ export function implement_(kernel) {
|
|
|
186
259
|
await changed(workspaceId, 'person', personId, 'updated');
|
|
187
260
|
return PeopleService.toPerson(row.updated);
|
|
188
261
|
}),
|
|
189
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* Ends employment and keeps the record. A terminated person is history, not a deletion.
|
|
264
|
+
*
|
|
265
|
+
* The reason the dialog collects is written to `person_history` rather than to a column on
|
|
266
|
+
* `people`: "why did she leave" is a fact about the *event*, it is asked for by an audit
|
|
267
|
+
* alongside who ended the employment and when, and `person_history` is the append-only table
|
|
268
|
+
* that already answers exactly that question. It used to be accepted and dropped on the
|
|
269
|
+
* floor, which is the one thing an offboarding record must not do.
|
|
270
|
+
*/
|
|
190
271
|
offboard: scoped.people.offboard
|
|
191
272
|
.use(requires('hr.person.manage'))
|
|
192
273
|
.handler(async ({ input, context }) => {
|
|
@@ -205,9 +286,13 @@ export function implement_(kernel) {
|
|
|
205
286
|
.update(officeAssignments)
|
|
206
287
|
.set({ effectiveTo: input.on })
|
|
207
288
|
.where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.personId, input.personId), isNull(officeAssignments.effectiveTo)));
|
|
289
|
+
const reason = input.reason?.trim();
|
|
208
290
|
await svc.record(tx, input.workspaceId, input.personId, context.principal.userId ?? null, [
|
|
209
291
|
{ field: 'status', from: before.status, to: 'terminated' },
|
|
210
292
|
{ field: 'terminatedOn', from: before.terminatedOn, to: input.on },
|
|
293
|
+
// Only when there is one. A row saying the reason changed from nothing to nothing is
|
|
294
|
+
// noise in the trail somebody reads to find out what happened.
|
|
295
|
+
...(reason ? [{ field: 'terminationReason', from: null, to: reason }] : []),
|
|
211
296
|
]);
|
|
212
297
|
return { before, updated: updated };
|
|
213
298
|
});
|
|
@@ -222,12 +307,25 @@ export function implement_(kernel) {
|
|
|
222
307
|
return PeopleService.toPerson(row.updated);
|
|
223
308
|
}),
|
|
224
309
|
history: scoped.people.history.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
225
|
-
const
|
|
226
|
-
.
|
|
310
|
+
const where = [
|
|
311
|
+
eq(personHistory.workspaceId, input.workspaceId),
|
|
312
|
+
eq(personHistory.personId, input.personId),
|
|
313
|
+
];
|
|
314
|
+
// The id tiebreaker earns its keep here more than anywhere: one edit writes several rows
|
|
315
|
+
// in one statement, and `now()` is frozen for a transaction, so they all share an `at`.
|
|
316
|
+
const cursor = decodeCursor(input.cursor);
|
|
317
|
+
if (cursor)
|
|
318
|
+
where.push(after(personHistory.at, personHistory.id, 'desc', cursor));
|
|
319
|
+
const { items: rows, nextCursor } = paginate(
|
|
320
|
+
// `atText` rather than the `Date`: see `paginate`. `now()` is frozen for a transaction,
|
|
321
|
+
// so a single edit's rows all share an `at` to the microsecond, and a millisecond cursor
|
|
322
|
+
// would drop every one of them after the first page.
|
|
323
|
+
await tx
|
|
324
|
+
.select({ ...getTableColumns(personHistory), atText: sql `${personHistory.at}::text` })
|
|
227
325
|
.from(personHistory)
|
|
228
|
-
.where(and(
|
|
229
|
-
.orderBy(desc(personHistory.at))
|
|
230
|
-
.limit(input.limit);
|
|
326
|
+
.where(and(...where))
|
|
327
|
+
.orderBy(desc(personHistory.at), desc(personHistory.id))
|
|
328
|
+
.limit(input.limit + 1), input.limit, (r) => [r.atText, r.id]);
|
|
231
329
|
return {
|
|
232
330
|
items: rows.map((r) => ({
|
|
233
331
|
id: r.id,
|
|
@@ -238,7 +336,7 @@ export function implement_(kernel) {
|
|
|
238
336
|
actorId: r.actorId,
|
|
239
337
|
source: r.source,
|
|
240
338
|
})),
|
|
241
|
-
nextCursor
|
|
339
|
+
nextCursor,
|
|
242
340
|
};
|
|
243
341
|
})),
|
|
244
342
|
sensitive: {
|
|
@@ -678,10 +776,20 @@ export function implement_(kernel) {
|
|
|
678
776
|
await changed(input.workspaceId, 'office', row.id, 'updated');
|
|
679
777
|
return toOffice(row);
|
|
680
778
|
}),
|
|
779
|
+
/**
|
|
780
|
+
* The office's people, and its headcount.
|
|
781
|
+
*
|
|
782
|
+
* One join rather than the two round trips this used to be. The old shape limited the
|
|
783
|
+
* *assignments* and then sorted the people it had fetched, so a page was an arbitrary subset
|
|
784
|
+
* put in alphabetical order — and it reported `rows.length` as `total`, which told an office
|
|
785
|
+
* of forty that it had twenty as soon as one page stopped holding everybody. The headcount is
|
|
786
|
+
* counted now, over the same predicate and without the cursor.
|
|
787
|
+
*/
|
|
681
788
|
people: scoped.offices.people
|
|
682
789
|
.use(cap('offices'))
|
|
683
790
|
.use(requires('hr.office.view'))
|
|
684
791
|
.handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
792
|
+
const here = and(eq(people.id, officeAssignments.personId), eq(people.workspaceId, officeAssignments.workspaceId));
|
|
685
793
|
const where = [
|
|
686
794
|
eq(officeAssignments.workspaceId, input.workspaceId),
|
|
687
795
|
eq(officeAssignments.officeId, input.officeId),
|
|
@@ -689,26 +797,30 @@ export function implement_(kernel) {
|
|
|
689
797
|
];
|
|
690
798
|
if (input.primaryOnly)
|
|
691
799
|
where.push(eq(officeAssignments.isPrimary, true));
|
|
692
|
-
const
|
|
693
|
-
.select()
|
|
800
|
+
const [total] = await tx
|
|
801
|
+
.select({ n: count() })
|
|
802
|
+
.from(officeAssignments)
|
|
803
|
+
.innerJoin(people, here)
|
|
804
|
+
.where(and(...where));
|
|
805
|
+
const cursor = decodeCursor(input.cursor);
|
|
806
|
+
if (cursor)
|
|
807
|
+
where.push(after(people.displayName, people.id, 'asc', cursor));
|
|
808
|
+
const { items: rows, nextCursor } = paginate(await tx
|
|
809
|
+
.select({ person: people, isPrimary: officeAssignments.isPrimary })
|
|
694
810
|
.from(officeAssignments)
|
|
811
|
+
.innerJoin(people, here)
|
|
695
812
|
.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));
|
|
813
|
+
.orderBy(asc(people.displayName), asc(people.id))
|
|
814
|
+
.limit(input.limit + 1), input.limit, (r) => [r.person.displayName, r.person.id]);
|
|
705
815
|
return {
|
|
706
816
|
items: rows.map((r) => ({
|
|
707
|
-
...PeopleService.toPerson(r),
|
|
708
|
-
|
|
817
|
+
...PeopleService.toPerson(r.person),
|
|
818
|
+
// Migration 0001 allows one open assignment per person per office, so the join
|
|
819
|
+
// cannot produce a person twice and this flag cannot disagree with itself.
|
|
820
|
+
isPrimaryHere: r.isPrimary,
|
|
709
821
|
})),
|
|
710
|
-
nextCursor
|
|
711
|
-
total:
|
|
822
|
+
nextCursor,
|
|
823
|
+
total: total?.n ?? 0,
|
|
712
824
|
};
|
|
713
825
|
})),
|
|
714
826
|
assign: scoped.offices.assign
|
|
@@ -1444,13 +1556,16 @@ export function implement_(kernel) {
|
|
|
1444
1556
|
const where = [eq(periods.workspaceId, input.workspaceId)];
|
|
1445
1557
|
if (input.kind)
|
|
1446
1558
|
where.push(eq(periods.kind, input.kind));
|
|
1447
|
-
const
|
|
1559
|
+
const cursor = decodeCursor(input.cursor);
|
|
1560
|
+
if (cursor)
|
|
1561
|
+
where.push(after(periods.startsOn, periods.id, 'desc', cursor));
|
|
1562
|
+
const { items, nextCursor } = paginate(await tx
|
|
1448
1563
|
.select()
|
|
1449
1564
|
.from(periods)
|
|
1450
1565
|
.where(and(...where))
|
|
1451
|
-
.orderBy(desc(periods.startsOn))
|
|
1452
|
-
.limit(input.limit);
|
|
1453
|
-
return { items:
|
|
1566
|
+
.orderBy(desc(periods.startsOn), desc(periods.id))
|
|
1567
|
+
.limit(input.limit + 1), input.limit, (r) => [r.startsOn, r.id]);
|
|
1568
|
+
return { items: items.map(toPeriod), nextCursor };
|
|
1454
1569
|
})),
|
|
1455
1570
|
create: scoped.periods.create
|
|
1456
1571
|
.use(cap('periods'))
|
|
@@ -1616,13 +1731,21 @@ export function implement_(kernel) {
|
|
|
1616
1731
|
];
|
|
1617
1732
|
if (!input.includeVoided)
|
|
1618
1733
|
where.push(isNull(punches.voidedByPunchId));
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1734
|
+
const cursor = decodeCursor(input.cursor);
|
|
1735
|
+
if (cursor)
|
|
1736
|
+
where.push(after(punches.at, punches.id, 'asc', cursor));
|
|
1737
|
+
const { items, nextCursor } = paginate(
|
|
1738
|
+
// `atText`, for the reason in `paginate`. Every insert here supplies a JS `Date`, so
|
|
1739
|
+
// nothing sub-millisecond is stored today and the bug is latent rather than live —
|
|
1740
|
+
// but the column carries `.defaultNow()`, so one insert that omits `at` would start
|
|
1741
|
+
// dropping punches off the end of a page with nothing to show for it.
|
|
1742
|
+
await tx
|
|
1743
|
+
.select({ ...getTableColumns(punches), atText: sql `${punches.at}::text` })
|
|
1621
1744
|
.from(punches)
|
|
1622
1745
|
.where(and(...where))
|
|
1623
|
-
.orderBy(asc(punches.at))
|
|
1624
|
-
.limit(input.limit);
|
|
1625
|
-
return { items:
|
|
1746
|
+
.orderBy(asc(punches.at), asc(punches.id))
|
|
1747
|
+
.limit(input.limit + 1), input.limit, (r) => [r.atText, r.id]);
|
|
1748
|
+
return { items: items.map(toPunch), nextCursor };
|
|
1626
1749
|
})),
|
|
1627
1750
|
void: scoped.attendance.punches.void
|
|
1628
1751
|
.use(cap('attendance'))
|
|
@@ -1669,13 +1792,16 @@ export function implement_(kernel) {
|
|
|
1669
1792
|
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1670
1793
|
where.push(eq(attendanceDays.personId, personId));
|
|
1671
1794
|
}
|
|
1672
|
-
const
|
|
1795
|
+
const cursor = decodeCursor(input.cursor);
|
|
1796
|
+
if (cursor)
|
|
1797
|
+
where.push(after(attendanceDays.businessDate, attendanceDays.id, 'asc', cursor));
|
|
1798
|
+
const { items, nextCursor } = paginate(await tx
|
|
1673
1799
|
.select()
|
|
1674
1800
|
.from(attendanceDays)
|
|
1675
1801
|
.where(and(...where))
|
|
1676
|
-
.orderBy(asc(attendanceDays.businessDate))
|
|
1677
|
-
.limit(input.limit);
|
|
1678
|
-
return { items:
|
|
1802
|
+
.orderBy(asc(attendanceDays.businessDate), asc(attendanceDays.id))
|
|
1803
|
+
.limit(input.limit + 1), input.limit, (r) => [r.businessDate, r.id]);
|
|
1804
|
+
return { items: items.map(toAttendanceDay), nextCursor };
|
|
1679
1805
|
})),
|
|
1680
1806
|
recompute: scoped.attendance.days.recompute
|
|
1681
1807
|
.use(cap('attendance'))
|
|
@@ -1813,13 +1939,16 @@ export function implement_(kernel) {
|
|
|
1813
1939
|
];
|
|
1814
1940
|
if (input.status?.length)
|
|
1815
1941
|
where.push(inArray(regularizations.status, input.status));
|
|
1816
|
-
const
|
|
1942
|
+
const cursor = decodeCursor(input.cursor);
|
|
1943
|
+
if (cursor)
|
|
1944
|
+
where.push(after(regularizations.businessDate, regularizations.id, 'desc', cursor));
|
|
1945
|
+
const { items, nextCursor } = paginate(await tx
|
|
1817
1946
|
.select()
|
|
1818
1947
|
.from(regularizations)
|
|
1819
1948
|
.where(and(...where))
|
|
1820
|
-
.orderBy(desc(regularizations.businessDate))
|
|
1821
|
-
.limit(input.limit);
|
|
1822
|
-
return { items:
|
|
1949
|
+
.orderBy(desc(regularizations.businessDate), desc(regularizations.id))
|
|
1950
|
+
.limit(input.limit + 1), input.limit, (r) => [r.businessDate, r.id]);
|
|
1951
|
+
return { items: items.map(toRegularization), nextCursor };
|
|
1823
1952
|
})),
|
|
1824
1953
|
request: scoped.attendance.regularizations.request
|
|
1825
1954
|
.use(cap('attendance'))
|
|
@@ -1971,13 +2100,19 @@ export function implement_(kernel) {
|
|
|
1971
2100
|
where.push(eq(leaveLedger.leaveTypeId, input.leaveTypeId));
|
|
1972
2101
|
if (input.periodYear)
|
|
1973
2102
|
where.push(eq(leaveLedger.periodYear, input.periodYear));
|
|
1974
|
-
const
|
|
2103
|
+
const cursor = decodeCursor(input.cursor);
|
|
2104
|
+
if (cursor)
|
|
2105
|
+
where.push(after(leaveLedger.effectiveOn, leaveLedger.id, 'desc', cursor));
|
|
2106
|
+
// The second sort key is the id rather than `created_at`, and means the same thing:
|
|
2107
|
+
// ids here are uuidv7, so they already run in creation order — and unlike `created_at`
|
|
2108
|
+
// no two rows can share one, which is what makes the cursor land in exactly one place.
|
|
2109
|
+
const { items, nextCursor } = paginate(await tx
|
|
1975
2110
|
.select()
|
|
1976
2111
|
.from(leaveLedger)
|
|
1977
2112
|
.where(and(...where))
|
|
1978
|
-
.orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.
|
|
1979
|
-
.limit(input.limit);
|
|
1980
|
-
return { items:
|
|
2113
|
+
.orderBy(desc(leaveLedger.effectiveOn), desc(leaveLedger.id))
|
|
2114
|
+
.limit(input.limit + 1), input.limit, (r) => [r.effectiveOn, r.id]);
|
|
2115
|
+
return { items: items.map(toLedgerEntry), nextCursor };
|
|
1981
2116
|
})),
|
|
1982
2117
|
},
|
|
1983
2118
|
adjust: scoped.leave.adjust
|
|
@@ -2013,11 +2148,34 @@ export function implement_(kernel) {
|
|
|
2013
2148
|
.use(requires('hr.leave.view'))
|
|
2014
2149
|
.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2015
2150
|
const where = [eq(leaveRequests.workspaceId, input.workspaceId)];
|
|
2016
|
-
|
|
2151
|
+
// An office is other people's absences, so asking for one costs the same permission
|
|
2152
|
+
// the team calendar costs. `hr.leave.view` alone must not become a way of reading the
|
|
2153
|
+
// whole company — which is exactly what a filter the server ignores had made of it.
|
|
2154
|
+
if (input.officeId) {
|
|
2155
|
+
await kernel.authz.require(context.principal, 'hr.leave.view_team', {
|
|
2156
|
+
kind: 'workspace',
|
|
2157
|
+
id: input.workspaceId,
|
|
2158
|
+
workspaceId: input.workspaceId,
|
|
2159
|
+
});
|
|
2160
|
+
const here = await tx
|
|
2161
|
+
.select({ personId: officeAssignments.personId })
|
|
2162
|
+
.from(officeAssignments)
|
|
2163
|
+
.where(and(eq(officeAssignments.workspaceId, input.workspaceId), eq(officeAssignments.officeId, input.officeId), isNull(officeAssignments.effectiveTo)));
|
|
2164
|
+
// An empty office matches nobody, not everybody.
|
|
2165
|
+
where.push(here.length
|
|
2166
|
+
? inArray(leaveRequests.personId, here.map((h) => h.personId))
|
|
2167
|
+
: sql `false`);
|
|
2168
|
+
}
|
|
2169
|
+
if (input.personId) {
|
|
2170
|
+
// Naming somebody else is the same act as naming their office, and was the one way
|
|
2171
|
+
// through this handler that cost nothing: `hr.leave.view` and a person id read
|
|
2172
|
+
// anybody's absences. `personFor` is the rule everywhere else in this module.
|
|
2173
|
+
await personFor(tx, input.workspaceId, context, input.personId);
|
|
2017
2174
|
where.push(eq(leaveRequests.personId, input.personId));
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
//
|
|
2175
|
+
}
|
|
2176
|
+
else if (!input.officeId && !context.principal.instanceAdmin) {
|
|
2177
|
+
// Without an explicit person or office, this is "my requests". Seeing everybody's by
|
|
2178
|
+
// default would leak the whole company's absences to any member with hr.leave.view.
|
|
2021
2179
|
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
2022
2180
|
where.push(me ? eq(leaveRequests.personId, me.id) : sql `false`);
|
|
2023
2181
|
}
|
|
@@ -2027,13 +2185,16 @@ export function implement_(kernel) {
|
|
|
2027
2185
|
where.push(gte(leaveRequests.endsOn, input.from));
|
|
2028
2186
|
if (input.to)
|
|
2029
2187
|
where.push(lte(leaveRequests.startsOn, input.to));
|
|
2030
|
-
const
|
|
2188
|
+
const cursor = decodeCursor(input.cursor);
|
|
2189
|
+
if (cursor)
|
|
2190
|
+
where.push(after(leaveRequests.startsOn, leaveRequests.id, 'desc', cursor));
|
|
2191
|
+
const { items, nextCursor } = paginate(await tx
|
|
2031
2192
|
.select()
|
|
2032
2193
|
.from(leaveRequests)
|
|
2033
2194
|
.where(and(...where))
|
|
2034
|
-
.orderBy(desc(leaveRequests.startsOn))
|
|
2035
|
-
.limit(input.limit);
|
|
2036
|
-
return { items:
|
|
2195
|
+
.orderBy(desc(leaveRequests.startsOn), desc(leaveRequests.id))
|
|
2196
|
+
.limit(input.limit + 1), input.limit, (r) => [r.startsOn, r.id]);
|
|
2197
|
+
return { items: items.map(toLeaveRequest), nextCursor };
|
|
2037
2198
|
})),
|
|
2038
2199
|
get: scoped.leave.requests.get
|
|
2039
2200
|
.use(cap('leave'))
|
|
@@ -2046,11 +2207,31 @@ export function implement_(kernel) {
|
|
|
2046
2207
|
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
2047
2208
|
return simulate(tx, input.workspaceId, personId, input);
|
|
2048
2209
|
})),
|
|
2210
|
+
/**
|
|
2211
|
+
* File a request.
|
|
2212
|
+
*
|
|
2213
|
+
* `idempotencyKey` is the contract's promise that a retried submission is safe, and the
|
|
2214
|
+
* server used to store the key without ever reading it. So a retry filed a second request,
|
|
2215
|
+
* or died on whichever unique index it reached first — the exploded days for a counted
|
|
2216
|
+
* absence, the key itself otherwise. Neither is what the caller was promised.
|
|
2217
|
+
*
|
|
2218
|
+
* `hr_leave_requests_idem_uq` is what there is to lean on: it already refuses a second row
|
|
2219
|
+
* for a key, so honouring the promise is a read before the insert and an answer for the
|
|
2220
|
+
* loser of the race.
|
|
2221
|
+
*/
|
|
2049
2222
|
create: scoped.leave.requests.create
|
|
2050
2223
|
.use(cap('leave'))
|
|
2051
2224
|
.use(requires('hr.leave.request'))
|
|
2052
2225
|
.handler(async ({ input, context }) => {
|
|
2053
|
-
const
|
|
2226
|
+
const key = input.idempotencyKey;
|
|
2227
|
+
// Held as a promise rather than awaited here, so that the duplicate recovery below is
|
|
2228
|
+
// one step at the end instead of ninety lines wrapped in a `try`.
|
|
2229
|
+
const filing = db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2230
|
+
// Before anything is locked or simulated: the same key is the same submission, and
|
|
2231
|
+
// answering it with the request it already filed is what "safe to retry" means.
|
|
2232
|
+
const already = key ? await byIdempotencyKey(tx, input.workspaceId, key) : undefined;
|
|
2233
|
+
if (already)
|
|
2234
|
+
return { request: already, personId: already.personId, replay: true };
|
|
2054
2235
|
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
2055
2236
|
// Everything that spends balance takes the cursor lock first, inside this
|
|
2056
2237
|
// transaction. Two overlapping requests for the last day cannot both read "enough".
|
|
@@ -2114,8 +2295,26 @@ export function implement_(kernel) {
|
|
|
2114
2295
|
if (raised.autoApproved)
|
|
2115
2296
|
await applyApproval(tx, input.workspaceId, request.id, context.principal.userId ?? null);
|
|
2116
2297
|
const [fresh] = await tx.select().from(leaveRequests).where(eq(leaveRequests.id, request.id));
|
|
2117
|
-
return { request: fresh,
|
|
2298
|
+
return { request: fresh, personId, replay: false };
|
|
2299
|
+
});
|
|
2300
|
+
const result = await filing.catch(async (err) => {
|
|
2301
|
+
// The other half of a double submit: two clicks a browser sends before the first has
|
|
2302
|
+
// answered both pass the read above, both reach the insert, and the unique index
|
|
2303
|
+
// refuses one. The loser lost a race it was never meant to enter, so it is answered
|
|
2304
|
+
// with the request that won rather than with a constraint error. The re-read needs a
|
|
2305
|
+
// new transaction — the failed one is aborted and will not answer another query.
|
|
2306
|
+
if (!key || !isUniqueViolation(err, 'hr_leave_requests_idem_uq'))
|
|
2307
|
+
throw err;
|
|
2308
|
+
const won = await db.withWorkspace(input.workspaceId, (tx) => byIdempotencyKey(tx, input.workspaceId, key));
|
|
2309
|
+
if (!won)
|
|
2310
|
+
throw err;
|
|
2311
|
+
return { request: won, personId: won.personId, replay: true };
|
|
2118
2312
|
});
|
|
2313
|
+
// A replay files nothing, so it announces nothing. Emitting `leaveRequested` again would
|
|
2314
|
+
// put a second card in an approver's inbox for one request — the outcome the key exists
|
|
2315
|
+
// to prevent, arriving by a different route.
|
|
2316
|
+
if (result.replay)
|
|
2317
|
+
return toLeaveRequest(result.request);
|
|
2119
2318
|
await kernel.emit(hrEvents.leaveRequested, {
|
|
2120
2319
|
requestId: result.request.id,
|
|
2121
2320
|
workspaceId: input.workspaceId,
|
|
@@ -2236,6 +2435,13 @@ export function implement_(kernel) {
|
|
|
2236
2435
|
/**
|
|
2237
2436
|
* Everything waiting on the caller. No permission: an inbox of what *you* must decide is
|
|
2238
2437
|
* yours by definition, and the engine only lists steps you are named on.
|
|
2438
|
+
*
|
|
2439
|
+
* The one paged list here that still answers `nextCursor: null`, and the only one that cannot
|
|
2440
|
+
* be fixed from this file: the page is cut inside `ApprovalService.inboxFor`, which takes a
|
|
2441
|
+
* limit and no cursor. Honouring one means widening that signature and pushing
|
|
2442
|
+
* `after(approvalRequests.requestedAt, approvalRequests.id, 'desc', …)` into its final query —
|
|
2443
|
+
* ordering it by `(requested_at, id)` on the way, so the cursor has something unique to land
|
|
2444
|
+
* on. Filtering the rows it returns would not do: it has already truncated them.
|
|
2239
2445
|
*/
|
|
2240
2446
|
inbox: scoped.approvals.inbox.handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2241
2447
|
const me = await svc.byUserId(tx, input.workspaceId, context.principal.userId ?? '');
|
|
@@ -2774,6 +2980,15 @@ export function implement_(kernel) {
|
|
|
2774
2980
|
});
|
|
2775
2981
|
return personId;
|
|
2776
2982
|
}
|
|
2983
|
+
/** The request a retry is a retry *of*, or undefined the first time a key is seen. */
|
|
2984
|
+
async function byIdempotencyKey(tx, workspaceId, key) {
|
|
2985
|
+
const [row] = await tx
|
|
2986
|
+
.select()
|
|
2987
|
+
.from(leaveRequests)
|
|
2988
|
+
.where(and(eq(leaveRequests.workspaceId, workspaceId), eq(leaveRequests.idempotencyKey, key)))
|
|
2989
|
+
.limit(1);
|
|
2990
|
+
return row;
|
|
2991
|
+
}
|
|
2777
2992
|
async function loadRequest(tx, workspaceId, requestId) {
|
|
2778
2993
|
const [row] = await tx
|
|
2779
2994
|
.select()
|