@kernhq/module-hr 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/contract/attendance.d.ts +35 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +25 -1
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts.map +1 -1
  6. package/dist/contract/capabilities.js +10 -0
  7. package/dist/contract/capabilities.js.map +1 -1
  8. package/dist/contract/events.d.ts +17 -16
  9. package/dist/contract/events.d.ts.map +1 -1
  10. package/dist/contract/events.js +23 -16
  11. package/dist/contract/events.js.map +1 -1
  12. package/dist/contract/models.d.ts +1 -0
  13. package/dist/contract/models.d.ts.map +1 -1
  14. package/dist/contract/models.js +11 -0
  15. package/dist/contract/models.js.map +1 -1
  16. package/dist/contract/router.d.ts +12 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +11 -3
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/contract/settings.d.ts +0 -1
  21. package/dist/contract/settings.d.ts.map +1 -1
  22. package/dist/contract/settings.js +11 -5
  23. package/dist/contract/settings.js.map +1 -1
  24. package/dist/server/index.d.ts +0 -1
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/jobs.d.ts.map +1 -1
  27. package/dist/server/jobs.js +13 -5
  28. package/dist/server/jobs.js.map +1 -1
  29. package/dist/server/router.d.ts +12 -0
  30. package/dist/server/router.d.ts.map +1 -1
  31. package/dist/server/router.js +99 -16
  32. package/dist/server/router.js.map +1 -1
  33. package/dist/server/services/access.d.ts +176 -0
  34. package/dist/server/services/access.d.ts.map +1 -0
  35. package/dist/server/services/access.js +260 -0
  36. package/dist/server/services/access.js.map +1 -0
  37. package/dist/server/services/people.d.ts +1 -0
  38. package/dist/server/services/people.d.ts.map +1 -1
  39. package/dist/server/services/people.js +3 -0
  40. package/dist/server/services/people.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/client/components/DayDetail.svelte +32 -14
  43. package/src/client/components/PersonPanel.svelte +66 -5
  44. package/src/client/components/redaction.ts +51 -0
  45. package/src/client/messages.ts +241 -22
  46. package/src/client/pages/DirectoryPage.svelte +45 -1
  47. package/src/client/permissions.ts +12 -4
  48. package/src/client/settings/AccrualSettings.svelte +596 -57
  49. package/src/client/settings/GeneralSettings.svelte +12 -9
  50. package/src/contract/attendance.ts +27 -1
  51. package/src/contract/capabilities.ts +10 -0
  52. package/src/contract/events.ts +23 -19
  53. package/src/contract/models.ts +11 -0
  54. package/src/contract/router.ts +11 -3
  55. package/src/contract/settings.ts +11 -5
@@ -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
- list: scoped.people.list.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
115
+ /**
116
+ * The directory: every person in the workspace, and as much of each as the reader may have.
117
+ *
118
+ * `hr.person.view` is a `member` default and stays one — a staff directory a colleague cannot
119
+ * open is a worse product, not a safer one. What the three widening keys decide is how much
120
+ * of each row comes back: `HrAccessService` resolves the people whose *personnel record* this
121
+ * reader may see, and everybody else arrives as a card with the four personnel fields nulled.
122
+ * The filters, the count and the cursor are unaffected — the page is the same page for
123
+ * everybody, which is what keeps `total` honest.
124
+ */
125
+ list: scoped.people.list.use(requires('hr.person.view')).handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
126
+ const visible = visibleSet(await access.visiblePersonIds(tx, input.workspaceId, context.principal));
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
- get: scoped.people.get
186
- .use(requires('hr.person.view'))
187
- .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => PeopleService.toPerson(await svc.load(tx, input.workspaceId, input.personId)))),
198
+ /**
199
+ * One person, at the width `people.list` would have shown them.
200
+ *
201
+ * Not a 404 for somebody outside the reader's record scope: the person exists, the directory
202
+ * says so, and answering "no such person" to a colleague looking up a work email would be a
203
+ * lie the whole product contradicts. They get the card.
204
+ */
205
+ get: scoped.people.get.use(requires('hr.person.view')).handler(({ input, context }) => db.withWorkspace(input.workspaceId, async (tx) => {
206
+ const visible = visibleSet(await access.visiblePersonIds(tx, input.workspaceId, context.principal));
207
+ return forViewer(PeopleService.toPerson(await svc.load(tx, input.workspaceId, input.personId)), visible);
208
+ })),
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
- history: scoped.people.history.use(requires('hr.person.view')).handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
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
- isNull(officeAssignments.effectiveTo),
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 row = await db.withWorkspace(input.workspaceId, async (tx) => {
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
- return fresh;
2036
+ // Same reason as leave: the approvers are told after this commits, never inside it.
2037
+ return {
2038
+ row: fresh,
2039
+ approval: { requestId: raised.request.id, approverIds: raised.firstStepApprovers },
2040
+ };
1994
2041
  });
1995
- await changed(input.workspaceId, 'regularization', row.id, 'created');
1996
- return toRegularization(row);
2042
+ if (filed.approval.approverIds.length)
2043
+ await kernel.emit(hrEvents.approvalRequested, {
2044
+ requestId: filed.approval.requestId,
2045
+ workspaceId: input.workspaceId,
2046
+ subjectType: 'regularization',
2047
+ subjectId: filed.row.id,
2048
+ approverIds: filed.approval.approverIds,
2049
+ }, { workspaceId: input.workspaceId, actorId: context.principal.userId });
2050
+ await changed(input.workspaceId, 'regularization', filed.row.id, 'created');
2051
+ return toRegularization(filed.row);
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
- return { request: fresh, personId, replay: false };
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)