@kernhq/module-hr 0.13.2 → 0.14.1
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/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 +99 -16
- 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 +39 -0
- package/src/client/messages.ts +241 -22
- package/src/client/pages/DirectoryPage.svelte +48 -1
- package/src/client/permissions.ts +6 -8
- package/src/client/settings/AccrualSettings.svelte +596 -57
- package/src/client/settings/GeneralSettings.svelte +12 -9
- 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
|
@@ -7,6 +7,7 @@ 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';
|
|
@@ -100,6 +101,7 @@ export function implement_(kernel) {
|
|
|
100
101
|
const cap = (id) => requiresCapability(MODULE_ID, id);
|
|
101
102
|
const resolve = new ResolveService();
|
|
102
103
|
const svc = new PeopleService(kernel);
|
|
104
|
+
const access = new HrAccessService(kernel);
|
|
103
105
|
const ledger = new LedgerService();
|
|
104
106
|
const approvals = new ApprovalService(kernel);
|
|
105
107
|
const policySvc = new PolicyService(resolve);
|
|
@@ -110,7 +112,18 @@ export function implement_(kernel) {
|
|
|
110
112
|
return os.router({
|
|
111
113
|
// ================================================================= people
|
|
112
114
|
people: {
|
|
113
|
-
|
|
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));
|
|
114
127
|
const where = [eq(people.workspaceId, input.workspaceId)];
|
|
115
128
|
if (input.q)
|
|
116
129
|
where.push(ilike(people.displayName, `%${input.q}%`));
|
|
@@ -170,21 +183,29 @@ export function implement_(kernel) {
|
|
|
170
183
|
: [];
|
|
171
184
|
const officeBy = new Map(assignments.map((a) => [a.personId, a]));
|
|
172
185
|
return {
|
|
173
|
-
items: rows.map((r) => ({
|
|
186
|
+
items: rows.map((r) => forViewer({
|
|
174
187
|
...PeopleService.toPerson(r),
|
|
175
188
|
// Spreading into a fresh literal drops the branded WorkspaceId that flowed through
|
|
176
189
|
// `toPerson`, so it is restored rather than widened to `string`.
|
|
177
190
|
workspaceId: r.workspaceId,
|
|
178
191
|
officeId: officeBy.get(r.id)?.officeId ?? null,
|
|
179
192
|
officeName: officeBy.get(r.id)?.name ?? null,
|
|
180
|
-
})),
|
|
193
|
+
}, visible)),
|
|
181
194
|
nextCursor,
|
|
182
195
|
total: total?.n ?? 0,
|
|
183
196
|
};
|
|
184
197
|
})),
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
})),
|
|
188
209
|
/**
|
|
189
210
|
* No permission check: everybody may read their own record, and a permission nobody can lack
|
|
190
211
|
* is noise in the role editor. Returns null rather than 404 when the signed-in user has no HR
|
|
@@ -306,7 +327,19 @@ export function implement_(kernel) {
|
|
|
306
327
|
await changed(input.workspaceId, 'person', input.personId, 'updated');
|
|
307
328
|
return PeopleService.toPerson(row.updated);
|
|
308
329
|
}),
|
|
309
|
-
|
|
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();
|
|
310
343
|
const where = [
|
|
311
344
|
eq(personHistory.workspaceId, input.workspaceId),
|
|
312
345
|
eq(personHistory.personId, input.personId),
|
|
@@ -788,12 +821,22 @@ export function implement_(kernel) {
|
|
|
788
821
|
people: scoped.offices.people
|
|
789
822
|
.use(cap('offices'))
|
|
790
823
|
.use(requires('hr.office.view'))
|
|
791
|
-
.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));
|
|
792
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.
|
|
793
836
|
const where = [
|
|
794
837
|
eq(officeAssignments.workspaceId, input.workspaceId),
|
|
795
838
|
eq(officeAssignments.officeId, input.officeId),
|
|
796
|
-
|
|
839
|
+
inForceOn(officeAssignments.effectiveFrom, officeAssignments.effectiveTo, todayIso()),
|
|
797
840
|
];
|
|
798
841
|
if (input.primaryOnly)
|
|
799
842
|
where.push(eq(officeAssignments.isPrimary, true));
|
|
@@ -813,12 +856,12 @@ export function implement_(kernel) {
|
|
|
813
856
|
.orderBy(asc(people.displayName), asc(people.id))
|
|
814
857
|
.limit(input.limit + 1), input.limit, (r) => [r.person.displayName, r.person.id]);
|
|
815
858
|
return {
|
|
816
|
-
items: rows.map((r) => ({
|
|
859
|
+
items: rows.map((r) => forViewer({
|
|
817
860
|
...PeopleService.toPerson(r.person),
|
|
818
861
|
// Migration 0001 allows one open assignment per person per office, so the join
|
|
819
862
|
// cannot produce a person twice and this flag cannot disagree with itself.
|
|
820
863
|
isPrimaryHere: r.isPrimary,
|
|
821
|
-
})),
|
|
864
|
+
}, visible)),
|
|
822
865
|
nextCursor,
|
|
823
866
|
total: total?.n ?? 0,
|
|
824
867
|
};
|
|
@@ -1954,7 +1997,7 @@ export function implement_(kernel) {
|
|
|
1954
1997
|
.use(cap('attendance'))
|
|
1955
1998
|
.use(requires('hr.attendance.punch'))
|
|
1956
1999
|
.handler(async ({ input, context }) => {
|
|
1957
|
-
const
|
|
2000
|
+
const filed = await db.withWorkspace(input.workspaceId, async (tx) => {
|
|
1958
2001
|
const personId = await personFor(tx, input.workspaceId, context, input.personId);
|
|
1959
2002
|
const [created] = await tx
|
|
1960
2003
|
.insert(regularizations)
|
|
@@ -1990,10 +2033,22 @@ export function implement_(kernel) {
|
|
|
1990
2033
|
.select()
|
|
1991
2034
|
.from(regularizations)
|
|
1992
2035
|
.where(eq(regularizations.id, created.id));
|
|
1993
|
-
|
|
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
|
+
};
|
|
1994
2041
|
});
|
|
1995
|
-
|
|
1996
|
-
|
|
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);
|
|
1997
2052
|
}),
|
|
1998
2053
|
},
|
|
1999
2054
|
},
|
|
@@ -2295,7 +2350,15 @@ export function implement_(kernel) {
|
|
|
2295
2350
|
if (raised.autoApproved)
|
|
2296
2351
|
await applyApproval(tx, input.workspaceId, request.id, context.principal.userId ?? null);
|
|
2297
2352
|
const [fresh] = await tx.select().from(leaveRequests).where(eq(leaveRequests.id, request.id));
|
|
2298
|
-
|
|
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
|
+
};
|
|
2299
2362
|
});
|
|
2300
2363
|
const result = await filing.catch(async (err) => {
|
|
2301
2364
|
// The other half of a double submit: two clicks a browser sends before the first has
|
|
@@ -2322,6 +2385,18 @@ export function implement_(kernel) {
|
|
|
2322
2385
|
startsOn: input.startsOn,
|
|
2323
2386
|
endsOn: input.endsOn,
|
|
2324
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 });
|
|
2325
2400
|
await changed(input.workspaceId, 'leave_request', result.request.id, 'created');
|
|
2326
2401
|
return toLeaveRequest(result.request);
|
|
2327
2402
|
}),
|
|
@@ -2660,6 +2735,14 @@ export function implement_(kernel) {
|
|
|
2660
2735
|
},
|
|
2661
2736
|
// ================================================================= custom fields
|
|
2662
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
|
+
*/
|
|
2663
2746
|
list: scoped.fields.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
|
|
2664
2747
|
const where = [eq(customFieldDefs.workspaceId, input.workspaceId)];
|
|
2665
2748
|
if (!input.includeArchived)
|