@kernhq/module-hr 0.1.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 (75) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +71 -0
  3. package/dist/contract/capabilities.d.ts +45 -0
  4. package/dist/contract/capabilities.d.ts.map +1 -0
  5. package/dist/contract/capabilities.js +114 -0
  6. package/dist/contract/capabilities.js.map +1 -0
  7. package/dist/contract/events.d.ts +74 -0
  8. package/dist/contract/events.d.ts.map +1 -0
  9. package/dist/contract/events.js +63 -0
  10. package/dist/contract/events.js.map +1 -0
  11. package/dist/contract/index.d.ts +15 -0
  12. package/dist/contract/index.d.ts.map +1 -0
  13. package/dist/contract/index.js +15 -0
  14. package/dist/contract/index.js.map +1 -0
  15. package/dist/contract/models.d.ts +457 -0
  16. package/dist/contract/models.d.ts.map +1 -0
  17. package/dist/contract/models.js +381 -0
  18. package/dist/contract/models.js.map +1 -0
  19. package/dist/contract/permissions.d.ts +174 -0
  20. package/dist/contract/permissions.d.ts.map +1 -0
  21. package/dist/contract/permissions.js +209 -0
  22. package/dist/contract/permissions.js.map +1 -0
  23. package/dist/contract/router.d.ts +2720 -0
  24. package/dist/contract/router.d.ts.map +1 -0
  25. package/dist/contract/router.js +520 -0
  26. package/dist/contract/router.js.map +1 -0
  27. package/dist/contract/settings.d.ts +20 -0
  28. package/dist/contract/settings.d.ts.map +1 -0
  29. package/dist/contract/settings.js +34 -0
  30. package/dist/contract/settings.js.map +1 -0
  31. package/dist/policy/calendar.d.ts +64 -0
  32. package/dist/policy/calendar.d.ts.map +1 -0
  33. package/dist/policy/calendar.js +113 -0
  34. package/dist/policy/calendar.js.map +1 -0
  35. package/dist/server/index.d.ts +8 -0
  36. package/dist/server/index.d.ts.map +1 -0
  37. package/dist/server/index.js +141 -0
  38. package/dist/server/index.js.map +1 -0
  39. package/dist/server/packs/index.d.ts +50 -0
  40. package/dist/server/packs/index.d.ts.map +1 -0
  41. package/dist/server/packs/index.js +121 -0
  42. package/dist/server/packs/index.js.map +1 -0
  43. package/dist/server/router.d.ts +3095 -0
  44. package/dist/server/router.d.ts.map +1 -0
  45. package/dist/server/router.js +1537 -0
  46. package/dist/server/router.js.map +1 -0
  47. package/dist/server/schema.d.ts +2828 -0
  48. package/dist/server/schema.d.ts.map +1 -0
  49. package/dist/server/schema.js +313 -0
  50. package/dist/server/schema.js.map +1 -0
  51. package/dist/server/services/db.d.ts +22 -0
  52. package/dist/server/services/db.d.ts.map +1 -0
  53. package/dist/server/services/db.js +23 -0
  54. package/dist/server/services/db.js.map +1 -0
  55. package/dist/server/services/people.d.ts +110 -0
  56. package/dist/server/services/people.d.ts.map +1 -0
  57. package/dist/server/services/people.js +182 -0
  58. package/dist/server/services/people.js.map +1 -0
  59. package/dist/server/services/resolve.d.ts +66 -0
  60. package/dist/server/services/resolve.d.ts.map +1 -0
  61. package/dist/server/services/resolve.js +145 -0
  62. package/dist/server/services/resolve.js.map +1 -0
  63. package/migrations/0000_init.sql +223 -0
  64. package/migrations/0001_rls.sql +141 -0
  65. package/migrations/meta/0000_snapshot.json +1715 -0
  66. package/migrations/meta/_journal.json +20 -0
  67. package/package.json +59 -0
  68. package/src/client/index.ts +62 -0
  69. package/src/contract/capabilities.ts +117 -0
  70. package/src/contract/events.ts +84 -0
  71. package/src/contract/index.ts +14 -0
  72. package/src/contract/models.ts +437 -0
  73. package/src/contract/permissions.ts +217 -0
  74. package/src/contract/router.ts +611 -0
  75. package/src/contract/settings.ts +35 -0
@@ -0,0 +1,437 @@
1
+ import { Timestamp, WorkspaceId } from '@kernhq/contracts'
2
+ import { z } from 'zod'
3
+
4
+ /** Lowercase, 2-32 characters. Names the API prefix, the Postgres schema `mod_hr` and every event. */
5
+ export const MODULE_ID = 'hr'
6
+
7
+ /** A calendar date with no time and no zone: `2026-08-24`. */
8
+ export const IsoDate = z.iso.date()
9
+ export type IsoDate = z.infer<typeof IsoDate>
10
+
11
+ /**
12
+ * An IANA zone name, never an offset.
13
+ *
14
+ * `+03:00` is a fact about one instant; `Europe/Istanbul` is a fact about a place, and only the
15
+ * second survives a daylight-saving transition. Everything in this module that has to turn a wall
16
+ * clock into an instant reads one of these.
17
+ */
18
+ export const TimeZone = z.string().min(1).max(64)
19
+
20
+ /** ISO 3166-1 alpha-2, upper case: `TR`, `NL`, `DE`. */
21
+ export const CountryCode = z
22
+ .string()
23
+ .length(2)
24
+ .regex(/^[A-Z]{2}$/)
25
+ /** ISO 3166-2 subdivision, without the country prefix: `34` for Istanbul, `BY` for Bavaria. */
26
+ export const RegionCode = z.string().min(1).max(8)
27
+
28
+ /** `09:00` — a wall clock reading, meaningless until a date and a zone are supplied. */
29
+ export const WallClock = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/)
30
+
31
+ const ws = { workspaceId: WorkspaceId }
32
+
33
+ // =====================================================================================
34
+ // people
35
+ // =====================================================================================
36
+
37
+ export const PersonStatus = z.enum(['onboarding', 'active', 'on_leave', 'offboarding', 'terminated'])
38
+ export type PersonStatus = z.infer<typeof PersonStatus>
39
+
40
+ export const Person = z.object({
41
+ id: z.uuid(),
42
+ ...ws,
43
+ /**
44
+ * The Kern account, when there is one.
45
+ *
46
+ * Nullable on purpose. Plenty of employees never sign in — factory floor, drivers, seasonal staff
47
+ * — and a directory that can only hold people with logins is not a directory. It also means HR can
48
+ * be populated before anyone is invited.
49
+ */
50
+ userId: z.uuid().nullable(),
51
+ employeeNo: z.string().min(1).max(32).nullable(),
52
+ displayName: z.string().min(1).max(160),
53
+ workEmail: z.email().max(254).nullable(),
54
+ personalEmail: z.email().max(254).nullable(),
55
+ phone: z.string().max(32).nullable(),
56
+ photoFileId: z.uuid().nullable(),
57
+ status: PersonStatus,
58
+ hiredOn: IsoDate.nullable(),
59
+ terminatedOn: IsoDate.nullable(),
60
+ /** Overrides the primary office's zone for somebody who genuinely works elsewhere. */
61
+ timezone: TimeZone.nullable(),
62
+ custom: z.record(z.string(), z.unknown()),
63
+ createdAt: Timestamp,
64
+ updatedAt: Timestamp,
65
+ })
66
+ export type Person = z.infer<typeof Person>
67
+
68
+ /**
69
+ * The fields that need a second permission.
70
+ *
71
+ * A separate shape, not optional fields on `Person`, because optional fields get returned by
72
+ * accident. Somebody with `hr.person.view` can read the directory; reading a national identity
73
+ * number takes `hr.person.view_sensitive`, and the two never travel in the same object.
74
+ */
75
+ export const PersonSensitive = z.object({
76
+ personId: z.uuid(),
77
+ ...ws,
78
+ nationalId: z.string().max(64).nullable(),
79
+ birthDate: IsoDate.nullable(),
80
+ iban: z.string().max(48).nullable(),
81
+ emergencyContact: z
82
+ .object({
83
+ name: z.string().max(160),
84
+ relationship: z.string().max(64).optional(),
85
+ phone: z.string().max(32),
86
+ })
87
+ .nullable(),
88
+ })
89
+ export type PersonSensitive = z.infer<typeof PersonSensitive>
90
+
91
+ // =====================================================================================
92
+ // employment — effective-dated
93
+ // =====================================================================================
94
+
95
+ export const EmploymentType = z.enum([
96
+ 'full_time',
97
+ 'part_time',
98
+ 'contract',
99
+ 'intern',
100
+ 'temporary',
101
+ 'freelance',
102
+ ])
103
+ export type EmploymentType = z.infer<typeof EmploymentType>
104
+
105
+ /**
106
+ * One row per period a person's job was a given shape. Never updated in place.
107
+ *
108
+ * Titles, managers, departments and hours all change, and a system that overwrites them cannot
109
+ * answer "who did she report to in March", which is the question a leave approval from March needs.
110
+ * A change closes the current row (`effectiveTo`) and opens a new one; `effectiveTo === null` is the
111
+ * present. The database refuses overlaps.
112
+ *
113
+ * **Office is deliberately not here.** People change desks and change jobs on different days, and
114
+ * folding the two together forces a fake promotion every time somebody relocates. See
115
+ * `OfficeAssignment`.
116
+ */
117
+ export const Employment = z.object({
118
+ id: z.uuid(),
119
+ ...ws,
120
+ personId: z.uuid(),
121
+ effectiveFrom: IsoDate,
122
+ effectiveTo: IsoDate.nullable(),
123
+ orgUnitId: z.uuid().nullable(),
124
+ positionId: z.uuid().nullable(),
125
+ legalEntityId: z.uuid().nullable(),
126
+ costCenterId: z.uuid().nullable(),
127
+ managerPersonId: z.uuid().nullable(),
128
+ employmentType: EmploymentType,
129
+ /** 1.0 is full time; 0.5 is a half-time contract. Drives proration everywhere. */
130
+ fte: z.number().min(0).max(1),
131
+ contractHoursWeek: z.number().min(0).max(168).nullable(),
132
+ reason: z.string().max(200).nullable(),
133
+ createdAt: Timestamp,
134
+ })
135
+ export type Employment = z.infer<typeof Employment>
136
+
137
+ // =====================================================================================
138
+ // org structure
139
+ // =====================================================================================
140
+
141
+ export const OrgUnit = z.object({
142
+ id: z.uuid(),
143
+ ...ws,
144
+ parentId: z.uuid().nullable(),
145
+ /** Materialised ltree path, e.g. `root.engineering.platform`. Read-only: `org.units.move` sets it. */
146
+ path: z.string().max(512),
147
+ name: z.string().min(1).max(160),
148
+ code: z.string().max(32).nullable(),
149
+ headPersonId: z.uuid().nullable(),
150
+ archivedAt: Timestamp.nullable(),
151
+ })
152
+ export type OrgUnit = z.infer<typeof OrgUnit>
153
+
154
+ export const Position = z.object({
155
+ id: z.uuid(),
156
+ ...ws,
157
+ title: z.string().min(1).max(160),
158
+ code: z.string().max(32).nullable(),
159
+ jobFamily: z.string().max(64).nullable(),
160
+ level: z.string().max(32).nullable(),
161
+ archivedAt: Timestamp.nullable(),
162
+ })
163
+ export type Position = z.infer<typeof Position>
164
+
165
+ // =====================================================================================
166
+ // offices — the unit of inheritance
167
+ // =====================================================================================
168
+
169
+ export const OfficeKind = z.enum([
170
+ 'head_office',
171
+ 'branch',
172
+ 'site',
173
+ 'warehouse',
174
+ 'store',
175
+ /** Where remote people belong. Carries a country, a zone and a calendar like anywhere else. */
176
+ 'remote',
177
+ ])
178
+ export type OfficeKind = z.infer<typeof OfficeKind>
179
+
180
+ /**
181
+ * A place the company operates, and the thing a person inherits from.
182
+ *
183
+ * One workspace, many offices: a head office in Istanbul, a branch in Amsterdam, two sites in the
184
+ * same city, and the remote people. Each carries what differs by geography — country, timezone,
185
+ * working week, holidays — so "which days am I off" has a local answer without a workspace per
186
+ * country.
187
+ *
188
+ * A workspace always has exactly one office even when the `offices` capability is off; it is created
189
+ * from the workspace country and never shown. That is what lets the capability be a *reveal* rather
190
+ * than a migration, and it is why nothing in this module has a "no office" branch.
191
+ */
192
+ export const Office = z.object({
193
+ id: z.uuid(),
194
+ ...ws,
195
+ name: z.string().min(1).max(160),
196
+ code: z.string().max(32).nullable(),
197
+ kind: OfficeKind,
198
+ /** A campus with buildings. This is geography, *not* the org chart — see `OrgUnit`. */
199
+ parentOfficeId: z.uuid().nullable(),
200
+ legalEntityId: z.uuid().nullable(),
201
+ country: CountryCode,
202
+ region: RegionCode.nullable(),
203
+ city: z.string().max(120).nullable(),
204
+ timezone: TimeZone,
205
+ calendarId: z.uuid().nullable(),
206
+ address: z
207
+ .object({
208
+ line1: z.string().max(200).optional(),
209
+ line2: z.string().max(200).optional(),
210
+ postalCode: z.string().max(32).optional(),
211
+ })
212
+ .nullable(),
213
+ /** Exactly one office per workspace carries this. Where a new person lands with no assignment. */
214
+ isDefault: z.boolean(),
215
+ headPersonId: z.uuid().nullable(),
216
+ archivedAt: Timestamp.nullable(),
217
+ createdAt: Timestamp,
218
+ })
219
+ export type Office = z.infer<typeof Office>
220
+
221
+ /**
222
+ * Who works where, over time.
223
+ *
224
+ * A person may hold several of these at once — somebody splitting the week between two sites — but
225
+ * **exactly one is primary at any date, and only the primary decides.** Non-primary offices grant
226
+ * presence: the person appears in that office's directory, its local HR can see them, and later its
227
+ * geofence will accept their punch. They never decide which holidays apply, which policy applies, or
228
+ * which timezone the person's day is attributed in.
229
+ *
230
+ * Any other rule turns "how many days off do I have" into a question with two answers, which is not
231
+ * a thing an employee or an auditor will accept.
232
+ */
233
+ export const OfficeAssignment = z.object({
234
+ id: z.uuid(),
235
+ ...ws,
236
+ personId: z.uuid(),
237
+ officeId: z.uuid(),
238
+ isPrimary: z.boolean(),
239
+ effectiveFrom: IsoDate,
240
+ effectiveTo: IsoDate.nullable(),
241
+ reason: z.string().max(200).nullable(),
242
+ createdAt: Timestamp,
243
+ })
244
+ export type OfficeAssignment = z.infer<typeof OfficeAssignment>
245
+
246
+ /**
247
+ * Who actually employs somebody.
248
+ *
249
+ * Distinct from the office: two Turkish offices can share one entity, and one office can host two.
250
+ * It exists because payroll is filed per legal employer, never per workspace — so a group with a
251
+ * Dutch B.V. and a Turkish A.Ş. closes two different payrolls out of one Kern workspace.
252
+ */
253
+ export const LegalEntity = z.object({
254
+ id: z.uuid(),
255
+ ...ws,
256
+ name: z.string().min(1).max(200),
257
+ registrationNo: z.string().max(64).nullable(),
258
+ taxNo: z.string().max(64).nullable(),
259
+ country: CountryCode,
260
+ /** ISO 4217. */
261
+ currency: z.string().length(3).nullable(),
262
+ archivedAt: Timestamp.nullable(),
263
+ })
264
+ export type LegalEntity = z.infer<typeof LegalEntity>
265
+
266
+ export const CostCenter = z.object({
267
+ id: z.uuid(),
268
+ ...ws,
269
+ code: z.string().min(1).max(32),
270
+ name: z.string().min(1).max(160),
271
+ officeId: z.uuid().nullable(),
272
+ orgUnitId: z.uuid().nullable(),
273
+ legalEntityId: z.uuid().nullable(),
274
+ archivedAt: Timestamp.nullable(),
275
+ })
276
+ export type CostCenter = z.infer<typeof CostCenter>
277
+
278
+ // =====================================================================================
279
+ // custom fields and documents
280
+ // =====================================================================================
281
+
282
+ export const FieldType = z.enum(['text', 'number', 'date', 'select', 'multi_select', 'boolean', 'url'])
283
+
284
+ export const CustomFieldDef = z.object({
285
+ id: z.uuid(),
286
+ ...ws,
287
+ key: z
288
+ .string()
289
+ .min(1)
290
+ .max(48)
291
+ .regex(/^[a-z][a-z0-9_]*$/),
292
+ name: z.string().min(1).max(120),
293
+ type: FieldType,
294
+ options: z.array(z.object({ value: z.string(), label: z.string() })).nullable(),
295
+ required: z.boolean(),
296
+ /** Needs `hr.person.view_sensitive`, like a national identity number. */
297
+ sensitive: z.boolean(),
298
+ section: z.enum(['profile', 'employment', 'other']),
299
+ order: z.number().int(),
300
+ archivedAt: Timestamp.nullable(),
301
+ })
302
+ export type CustomFieldDef = z.infer<typeof CustomFieldDef>
303
+
304
+ export const PersonDocument = z.object({
305
+ id: z.uuid(),
306
+ ...ws,
307
+ personId: z.uuid(),
308
+ fileId: z.uuid(),
309
+ name: z.string().min(1).max(200),
310
+ kind: z.string().max(48),
311
+ issuedOn: IsoDate.nullable(),
312
+ expiresOn: IsoDate.nullable(),
313
+ uploadedBy: z.uuid().nullable(),
314
+ createdAt: Timestamp,
315
+ })
316
+ export type PersonDocument = z.infer<typeof PersonDocument>
317
+
318
+ // =====================================================================================
319
+ // calendars
320
+ // =====================================================================================
321
+
322
+ export const CalendarDayKind = z.enum([
323
+ 'public_holiday',
324
+ 'religious',
325
+ 'company_closure',
326
+ 'half_day',
327
+ /** The country has a holiday and this company works it. `workingFraction` is 1. */
328
+ 'working_override',
329
+ /** The Friday between a Thursday holiday and the weekend. */
330
+ 'bridge',
331
+ ])
332
+ export type CalendarDayKind = z.infer<typeof CalendarDayKind>
333
+
334
+ /** Which weekdays are worked, and how much of each. Iran is `{fri: 0}` with a half Thursday. */
335
+ export const WorkingWeek = z.object({
336
+ mon: z.number().min(0).max(1).default(1),
337
+ tue: z.number().min(0).max(1).default(1),
338
+ wed: z.number().min(0).max(1).default(1),
339
+ thu: z.number().min(0).max(1).default(1),
340
+ fri: z.number().min(0).max(1).default(1),
341
+ sat: z.number().min(0).max(1).default(0),
342
+ sun: z.number().min(0).max(1).default(0),
343
+ })
344
+ export type WorkingWeek = z.infer<typeof WorkingWeek>
345
+
346
+ /**
347
+ * A set of non-working days, composed rather than copied.
348
+ *
349
+ * `extendsId` points at a country pack, and this calendar's own days sit on top: *"Amsterdam = the
350
+ * NL pack, plus our two company closures, minus the one we work through"*. Copying the pack into
351
+ * every office instead would mean a yearly pack refresh reconciling N copies against N sets of local
352
+ * edits, which is exactly the problem per-day `source` exists to avoid.
353
+ */
354
+ export const Calendar = z.object({
355
+ id: z.uuid(),
356
+ ...ws,
357
+ name: z.string().min(1).max(160),
358
+ extendsId: z.uuid().nullable(),
359
+ country: CountryCode.nullable(),
360
+ region: RegionCode.nullable(),
361
+ workingWeek: WorkingWeek,
362
+ source: z.enum(['pack', 'custom']),
363
+ packKey: z.string().max(32).nullable(),
364
+ packVersion: z.string().max(32).nullable(),
365
+ archivedAt: Timestamp.nullable(),
366
+ })
367
+ export type Calendar = z.infer<typeof Calendar>
368
+
369
+ export const CalendarDay = z.object({
370
+ id: z.uuid(),
371
+ ...ws,
372
+ calendarId: z.uuid(),
373
+ date: IsoDate,
374
+ kind: CalendarDayKind,
375
+ name: z.string().min(1).max(160),
376
+ /** 0 is a full day off, 0.5 a half day, 1 a day worked despite the pack saying otherwise. */
377
+ workingFraction: z.number().min(0).max(1),
378
+ /**
379
+ * Per **day**, not per calendar. A pack upgrade replaces `pack` rows and never touches `custom`
380
+ * ones — which is the entire reason HR can safely add their own holidays to a pack calendar.
381
+ */
382
+ source: z.enum(['pack', 'custom']),
383
+ paid: z.boolean(),
384
+ note: z.string().max(500).nullable(),
385
+ })
386
+ export type CalendarDay = z.infer<typeof CalendarDay>
387
+
388
+ /** A day as the composed calendar sees it, with where it came from. */
389
+ export const ResolvedCalendarDay = CalendarDay.extend({
390
+ /** The calendar in the `extends` chain that contributed this day. */
391
+ fromCalendarId: z.uuid(),
392
+ fromCalendarName: z.string(),
393
+ /** True when a nearer calendar overrode a day the base calendar had. */
394
+ overrides: z.boolean(),
395
+ })
396
+ export type ResolvedCalendarDay = z.infer<typeof ResolvedCalendarDay>
397
+
398
+ // =====================================================================================
399
+ // resolution — the ladder, made inspectable
400
+ // =====================================================================================
401
+
402
+ export const ResolutionRung = z.enum([
403
+ 'person',
404
+ 'office',
405
+ 'legal_entity',
406
+ 'org_unit',
407
+ 'position',
408
+ 'workspace',
409
+ ])
410
+ export type ResolutionRung = z.infer<typeof ResolutionRung>
411
+
412
+ /**
413
+ * What actually applies to one person on one date, and which rung answered.
414
+ *
415
+ * The ladder is `person → primary office → org unit → workspace`, nearest wins, and it decides both
416
+ * the calendar and every policy. Exposing *which rung answered* is not a nicety: "why does Ayşe have
417
+ * different holidays from her team" is the support question this module will be asked most, and
418
+ * without this it is answered with a database session.
419
+ */
420
+ export const PersonResolution = z.object({
421
+ personId: z.uuid(),
422
+ on: IsoDate,
423
+ primaryOfficeId: z.uuid().nullable(),
424
+ primaryOfficeName: z.string().nullable(),
425
+ otherOfficeIds: z.array(z.uuid()),
426
+ country: CountryCode.nullable(),
427
+ timezone: TimeZone,
428
+ timezoneFrom: ResolutionRung,
429
+ calendarId: z.uuid().nullable(),
430
+ calendarFrom: ResolutionRung.nullable(),
431
+ workingWeek: WorkingWeek,
432
+ legalEntityId: z.uuid().nullable(),
433
+ orgUnitId: z.uuid().nullable(),
434
+ orgUnitPath: z.string().nullable(),
435
+ managerPersonId: z.uuid().nullable(),
436
+ })
437
+ export type PersonResolution = z.infer<typeof PersonResolution>
@@ -0,0 +1,217 @@
1
+ import { definePermissions } from '@kernhq/contracts'
2
+
3
+ /**
4
+ * Who may see and change what.
5
+ *
6
+ * HR holds the most sensitive data in the product, so the split is finer than other modules':
7
+ *
8
+ * - **Your own record is not a permission.** Everybody reads and edits their own profile; there is
9
+ * no `hr.person.view_self` because a permission somebody can never lack is noise in the role
10
+ * editor. The router enforces it by identity instead.
11
+ * - **Three widths of "other people".** `view_team` is the org-unit subtree you head plus your direct
12
+ * reports; `view_office` is everybody assigned to an office you administer, which is what a local
13
+ * HR person in Amsterdam holds; `view_all` is the workspace. None of them implies the others, so a
14
+ * country HR manager does not silently become a global one.
15
+ * - **Sensitive fields are their own pair.** A directory is `hr.person.view`. A national identity
16
+ * number, a birth date and a bank account are `hr.person.view_sensitive`, which nobody holds by
17
+ * default — not even an owner's role, though an owner passes every check anyway.
18
+ *
19
+ * `scope: 'object'` on the team and office reads is deliberate: `PermissionScopeKind` has no
20
+ * `org_unit` or `office` member, and adding one is a change to `@kernhq/contracts` that would have
21
+ * to roll through the kernel and core. Binding at object scope with the unit or office id gets the
22
+ * same result today; `HrAccessService` is what resolves it.
23
+ */
24
+ export const hrPermissions = definePermissions([
25
+ // ---------------------------------------------------------------- people
26
+ {
27
+ key: 'hr.person.view',
28
+ label: 'View the staff directory',
29
+ scope: 'workspace',
30
+ defaultRoles: ['owner', 'admin', 'member'],
31
+ dangerous: false,
32
+ },
33
+ {
34
+ key: 'hr.person.view_team',
35
+ label: "View your team's records",
36
+ scope: 'object',
37
+ defaultRoles: ['owner', 'admin'],
38
+ dangerous: false,
39
+ },
40
+ {
41
+ key: 'hr.person.view_office',
42
+ label: "View an office's records",
43
+ scope: 'object',
44
+ defaultRoles: ['owner', 'admin'],
45
+ dangerous: false,
46
+ },
47
+ {
48
+ key: 'hr.person.view_all',
49
+ label: 'View every record in the workspace',
50
+ scope: 'workspace',
51
+ defaultRoles: ['owner', 'admin'],
52
+ dangerous: false,
53
+ },
54
+ {
55
+ key: 'hr.person.manage',
56
+ label: 'Add, edit and offboard people',
57
+ scope: 'workspace',
58
+ defaultRoles: ['owner', 'admin'],
59
+ dangerous: false,
60
+ },
61
+ {
62
+ key: 'hr.person.view_sensitive',
63
+ label: 'View identity, birth date and bank details',
64
+ description: 'Personal data protected under GDPR and KVKK. Grant to HR only.',
65
+ scope: 'workspace',
66
+ // Nobody by default. An owner passes every check regardless of the grant, which is the one
67
+ // unavoidable hole; every other holder had to be given it deliberately.
68
+ defaultRoles: [],
69
+ dangerous: true,
70
+ },
71
+ {
72
+ key: 'hr.person.manage_sensitive',
73
+ label: 'Edit identity, birth date and bank details',
74
+ scope: 'workspace',
75
+ defaultRoles: [],
76
+ dangerous: true,
77
+ },
78
+
79
+ // ---------------------------------------------------------------- employment
80
+ {
81
+ key: 'hr.employment.view',
82
+ label: 'View employment records and history',
83
+ scope: 'workspace',
84
+ defaultRoles: ['owner', 'admin'],
85
+ dangerous: false,
86
+ },
87
+ {
88
+ key: 'hr.employment.manage',
89
+ label: 'Change job, manager, department or hours',
90
+ scope: 'workspace',
91
+ defaultRoles: ['owner', 'admin'],
92
+ dangerous: true,
93
+ },
94
+
95
+ // ---------------------------------------------------------------- org
96
+ {
97
+ key: 'hr.org.view',
98
+ label: 'View the org chart, departments and positions',
99
+ scope: 'workspace',
100
+ defaultRoles: ['owner', 'admin', 'member'],
101
+ dangerous: false,
102
+ },
103
+ {
104
+ key: 'hr.org.manage',
105
+ label: 'Change departments, positions and reporting lines',
106
+ scope: 'workspace',
107
+ defaultRoles: ['owner', 'admin'],
108
+ dangerous: false,
109
+ },
110
+
111
+ // ---------------------------------------------------------------- offices
112
+ {
113
+ key: 'hr.office.view',
114
+ label: 'View offices',
115
+ scope: 'workspace',
116
+ defaultRoles: ['owner', 'admin', 'member'],
117
+ dangerous: false,
118
+ },
119
+ {
120
+ key: 'hr.office.manage',
121
+ label: 'Add and edit offices',
122
+ scope: 'workspace',
123
+ defaultRoles: ['owner', 'admin'],
124
+ dangerous: false,
125
+ },
126
+ {
127
+ key: 'hr.office.assign',
128
+ label: 'Assign people to offices',
129
+ // Separate from `manage`: a local HR person moves people between their own offices without being
130
+ // able to create one or change its country, which would change everybody's holidays.
131
+ scope: 'object',
132
+ defaultRoles: ['owner', 'admin'],
133
+ dangerous: false,
134
+ },
135
+
136
+ // ---------------------------------------------------------------- legal entities
137
+ {
138
+ key: 'hr.entity.view',
139
+ label: 'View legal entities and cost centres',
140
+ scope: 'workspace',
141
+ defaultRoles: ['owner', 'admin'],
142
+ dangerous: false,
143
+ },
144
+ {
145
+ key: 'hr.entity.manage',
146
+ label: 'Add and edit legal entities and cost centres',
147
+ scope: 'workspace',
148
+ defaultRoles: ['owner'],
149
+ dangerous: false,
150
+ },
151
+
152
+ // ---------------------------------------------------------------- calendars
153
+ {
154
+ key: 'hr.calendar.view',
155
+ label: 'View holiday calendars',
156
+ scope: 'workspace',
157
+ defaultRoles: ['owner', 'admin', 'member', 'guest'],
158
+ dangerous: false,
159
+ },
160
+ {
161
+ key: 'hr.calendar.manage',
162
+ label: 'Add and edit holidays and closures',
163
+ scope: 'workspace',
164
+ defaultRoles: ['owner', 'admin'],
165
+ dangerous: false,
166
+ },
167
+
168
+ // ---------------------------------------------------------------- documents
169
+ {
170
+ key: 'hr.document.view',
171
+ label: 'View employee documents',
172
+ scope: 'workspace',
173
+ defaultRoles: [],
174
+ dangerous: true,
175
+ },
176
+ {
177
+ key: 'hr.document.manage',
178
+ label: 'Attach and remove employee documents',
179
+ scope: 'workspace',
180
+ defaultRoles: [],
181
+ dangerous: true,
182
+ },
183
+
184
+ // ---------------------------------------------------------------- fields
185
+ {
186
+ key: 'hr.field.manage',
187
+ label: 'Add and edit custom fields',
188
+ scope: 'workspace',
189
+ defaultRoles: ['owner', 'admin'],
190
+ dangerous: false,
191
+ },
192
+ ])
193
+
194
+ /** The keys, so nothing gates on a string somebody retyped. */
195
+ export const HR_PERMISSIONS = {
196
+ personView: 'hr.person.view',
197
+ personViewTeam: 'hr.person.view_team',
198
+ personViewOffice: 'hr.person.view_office',
199
+ personViewAll: 'hr.person.view_all',
200
+ personManage: 'hr.person.manage',
201
+ personViewSensitive: 'hr.person.view_sensitive',
202
+ personManageSensitive: 'hr.person.manage_sensitive',
203
+ employmentView: 'hr.employment.view',
204
+ employmentManage: 'hr.employment.manage',
205
+ orgView: 'hr.org.view',
206
+ orgManage: 'hr.org.manage',
207
+ officeView: 'hr.office.view',
208
+ officeManage: 'hr.office.manage',
209
+ officeAssign: 'hr.office.assign',
210
+ entityView: 'hr.entity.view',
211
+ entityManage: 'hr.entity.manage',
212
+ calendarView: 'hr.calendar.view',
213
+ calendarManage: 'hr.calendar.manage',
214
+ documentView: 'hr.document.view',
215
+ documentManage: 'hr.document.manage',
216
+ fieldManage: 'hr.field.manage',
217
+ } as const