@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.
- package/LICENSE +662 -0
- package/README.md +71 -0
- package/dist/contract/capabilities.d.ts +45 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +114 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +74 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +63 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +457 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +381 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +174 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +209 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +2720 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +520 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +34 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/policy/calendar.d.ts +64 -0
- package/dist/policy/calendar.d.ts.map +1 -0
- package/dist/policy/calendar.js +113 -0
- package/dist/policy/calendar.js.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +141 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/packs/index.d.ts +50 -0
- package/dist/server/packs/index.d.ts.map +1 -0
- package/dist/server/packs/index.js +121 -0
- package/dist/server/packs/index.js.map +1 -0
- package/dist/server/router.d.ts +3095 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +1537 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +2828 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +313 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/db.d.ts +22 -0
- package/dist/server/services/db.d.ts.map +1 -0
- package/dist/server/services/db.js +23 -0
- package/dist/server/services/db.js.map +1 -0
- package/dist/server/services/people.d.ts +110 -0
- package/dist/server/services/people.d.ts.map +1 -0
- package/dist/server/services/people.js +182 -0
- package/dist/server/services/people.js.map +1 -0
- package/dist/server/services/resolve.d.ts +66 -0
- package/dist/server/services/resolve.d.ts.map +1 -0
- package/dist/server/services/resolve.js +145 -0
- package/dist/server/services/resolve.js.map +1 -0
- package/migrations/0000_init.sql +223 -0
- package/migrations/0001_rls.sql +141 -0
- package/migrations/meta/0000_snapshot.json +1715 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +59 -0
- package/src/client/index.ts +62 -0
- package/src/contract/capabilities.ts +117 -0
- package/src/contract/events.ts +84 -0
- package/src/contract/index.ts +14 -0
- package/src/contract/models.ts +437 -0
- package/src/contract/permissions.ts +217 -0
- package/src/contract/router.ts +611 -0
- 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
|